Paskelbtas šaltinis
Atsisiųsti šaltinį ZIP
CH-J Server Manager
Naršyti aplankus ir failus konkrečiai programai išleisti.
CH-J / Server Manager
339 failai
core-20261014T184258Z-3fbed0b93fdf
Pridėta: Data neregistruota · Pastatytas:
1
"use strict";2
const http = require("node:http");3
const https = require("node:https");4
const http2 = require("node:http2");5
const net = require("node:net");6
const zlib = require("node:zlib");7
const crypto = require("node:crypto");8
const { verifyPeerIdentity, DiagnosticError, parseTarget, checkAbort, boundedSignal, now, stats, errorResult } = require("./common");9
const { runProcess } = require("./processTools");10
const MAX_BODY = 2 * 1024 * 1024;11
const PRIVATE_HEADERS = /^(?:set-cookie|cookie|authorization|proxy-authorization|www-authenticate|proxy-authenticate|authentication-info|proxy-authentication-info|set-cookie2)$/i;12
function safeHeaders(headers) {13
const result = {};14
for (const [key, value] of Object.entries(headers)) {15
if (key.startsWith(":")) continue;16
result[key.toLowerCase()] = PRIVATE_HEADERS.test(key) ? "[redacted]" : String(value).slice(0, 8192);17
}18
return result;19
}20
function securityHeaders(headers, httpsTarget) {21
const keys = ["strict-transport-security", "content-security-policy", "x-content-type-options", "referrer-policy", "permissions-policy", "x-frame-options"];22
return keys.map((name) => ({ name, value: headers[name] || null, status: headers[name] ? "present" : name === "strict-transport-security" && !httpsTarget ? "not-applicable" : name === "x-frame-options" && /(?:^|;)\s*frame-ancestors\s/i.test(headers["content-security-policy"] || "") ? "superseded-by-csp-frame-ancestors" : "missing", note: "Presence alone does not establish correct policy or website security." }));23
}24
function consume(stream, onEnd, fail) {25
let bytes = 0, chunks = [];26
stream.on("data", (chunk) => { bytes += chunk.length; if (bytes > MAX_BODY) { stream.destroy(); fail(new DiagnosticError("BODY_LIMIT")); return; } chunks.push(chunk); });27
stream.once("end", () => onEnd(Buffer.concat(chunks), bytes));28
stream.once("error", fail);29
}30
function nativeRequest(url, address, protocol, { signal, timeoutMs, encoding = "identity", agent, sessionCache, ca } = {}) {31
checkAbort(signal);32
return new Promise((resolve, reject) => {33
let request, session, socket, finished = false, connectAt = null, secureAt = null, firstAt = null, tcpStart = now(), reused = false;34
const start = tcpStart;35
const sessionError = (error) => finish(error);36
const finish = (error, result) => { if (finished) return; finished = true; session?.removeListener("error", sessionError); clearTimeout(timer); signal?.removeEventListener("abort", abort); if (error) { request?.destroy(); if (session) session.destroy(); reject(error); } else { if (session && !sessionCache) session.close(); resolve(result); } };37
const abort = () => finish(new DiagnosticError("CANCELLED"));38
const timer = setTimeout(() => finish(new DiagnosticError("TIMEOUT")), timeoutMs);39
signal?.addEventListener("abort", abort, { once: true });40
const lookup = (_host, options, callback) => options?.all ? callback(null, [{ address, family: net.isIP(address) }]) : callback(null, address, net.isIP(address));41
const tlsHost = url.hostname.replace(/^\[|\]$/g, "");42
const bind = (value) => {43
socket = value;44
if (!socket.connecting) return;45
socket.once("connect", () => { connectAt = now(); });46
socket.once("secureConnect", () => { secureAt = now(); });47
};48
const complete = (statusCode, headers, body, bytes, negotiated, alpn) => finish(null, { status: protocol === "1.1" && negotiated !== "1.1" ? "unsupported" : "success", negotiated, statusCode,49
serverIp: socket?.remoteAddress || address, port: Number(url.port || (url.protocol === "https:" ? 443 : 80)), headers: safeHeaders(headers),50
contentType: headers["content-type"] || null, contentLength: /^\d+$/.test(String(headers["content-length"])) ? Number(headers["content-length"]) : null,51
downloadedBytes: bytes, bodyHash: crypto.createHash("sha256").update(body).digest("hex"), _body: body,52
reused, alpn: alpn || null, timings: { tcpMs: reused ? null : connectAt === null ? null : connectAt - tcpStart,53
tlsMs: reused || url.protocol !== "https:" || secureAt === null ? null : secureAt - (connectAt ?? start),54
ttfbMs: firstAt === null ? null : firstAt - start, downloadMs: firstAt === null ? null : now() - firstAt, totalMs: now() - start },55
bytesPerSecond: bytes / Math.max(0.001, (now() - (firstAt ?? start)) / 1000) });56
try {57
if (protocol === "1.1") {58
const transport = url.protocol === "https:" ? https : http;59
request = transport.request(url, { method: "GET", lookup, family: net.isIP(address), agent: agent || false,60
rejectUnauthorized: true, ca, servername: net.isIP(tlsHost) ? "" : tlsHost, checkServerIdentity: (_name, cert) => verifyPeerIdentity(tlsHost, cert), ALPNProtocols: ["http/1.1"], maxHeaderSize: 32768,61
headers: { "accept-encoding": encoding, "user-agent": "CH-J-Server-Diagnostics/1", accept: "*/*" } }, (response) => {62
firstAt = now(); reused = Boolean(request.reusedSocket); socket = response.socket;63
consume(response, (body, bytes) => complete(response.statusCode, response.headers, body, bytes, response.httpVersion, socket?.alpnProtocol), (error) => finish(error));64
});65
request.once("socket", bind); request.once("error", (error) => finish(error)); request.end();66
} else {67
const key = url.origin + "|" + address;68
session = sessionCache?.get(key);69
if (session?.destroyed || session?.closed) session = null;70
reused = Boolean(session);71
if (!session) {72
session = http2.connect(url.origin, { lookup, family: net.isIP(address), rejectUnauthorized: true, ca,73
servername: net.isIP(tlsHost) ? "" : tlsHost, checkServerIdentity: (_name, cert) => verifyPeerIdentity(tlsHost, cert), ALPNProtocols: ["h2"], maxSessionMemory: 2, settings: { enablePush: false, maxHeaderListSize: 32768 } });74
session.on("error", () => {});75
sessionCache?.set(key, session);76
bind(session.socket);77
} else socket = session.socket;78
session.on("error", sessionError);79
const open = () => {80
if (finished) return;81
try {82
if (url.protocol === "https:" && session.alpnProtocol !== "h2") return finish(new DiagnosticError("HTTP2_NOT_NEGOTIATED"));83
request = session.request({ ":method": "GET", ":path": url.pathname + url.search, "accept-encoding": encoding, "user-agent": "CH-J-Server-Diagnostics/1" }, { endStream: true });84
request.once("response", (headers) => { firstAt = now(); consume(request, (body, bytes) => complete(headers[":status"], headers, body, bytes, "2", session.alpnProtocol), (error) => finish(error)); });85
request.once("error", (error) => finish(error));86
request.once("aborted", () => finish(new DiagnosticError("HTTP_ABORTED")));87
} catch (error) { finish(error); }88
};89
if (session.connecting) session.once("connect", open); else open();90
}91
} catch (error) { finish(error); }92
});93
}94
const { runtimePaths, validateRuntime, runtimeEnvironment } = require("./httpRuntime");95
async function getCurlCapability(runner = runProcess, signal) {96
checkAbort(signal);97
try {98
const runtime = runner === runProcess ? validateRuntime() : runtimePaths();99
const result = await runner(runtime.executable, ["--disable", "--version"], { timeoutMs: 3000, signal, env: runtimeEnvironment() });100
const output = result.stdout.toString(), version = output.match(/^curl (\d+)\.(\d+)\.(\d+)/);101
const http2 = /Features:.*\bHTTP2\b/.test(output), http3 = /Features:.*\bHTTP3\b/.test(output), tls = /Features:.*\bSSL\b/.test(output);102
const available = Boolean(version) && Number(version[1]) >= 8 && http2 && http3 && tls && (result.code === undefined || result.code === 0);103
return { available, version: version?.[0] || null, http2, http3, tls, platform: process.platform, arch: process.arch,104
tlsBackend: output.split("\n")[0].match(/(?:OpenSSL|LibreSSL|BoringSSL|AWS-LC)\/[^ ]+/)?.[0] || null,105
code: available ? null : "HTTP_RUNTIME_INCOMPATIBLE", raw: output.slice(0, 3000), executable: runtime.executable, caPath: runtime.caPath };106
} catch (error) {107
if (signal?.aborted) throw new DiagnosticError("CANCELLED");108
return { available: false, code: error.code?.startsWith("HTTP_RUNTIME_") ? error.code : error.code === "ENOENT" ? "HTTP_RUNTIME_MISSING" : "HTTP_RUNTIME_INVALID", platform: process.platform, arch: process.arch };109
}110
}111
function curlFailure(code, protocol) {112
return ({ 1: "HTTP_PROTOCOL_INCOMPATIBLE", 2: "HTTP_RUNTIME_INCOMPATIBLE", 4: "HTTP_RUNTIME_INCOMPATIBLE", 5: "DNS_FAILED", 6: "DNS_FAILED", 7: "CONNECTION_REFUSED", 28: protocol === "3" ? "HTTP3_UDP_TIMEOUT" : "TIMEOUT", 35: "TLS_HANDSHAKE_FAILED", 51: "TLS_CERT_INVALID", 58: "TLS_CERT_INVALID", 60: "TLS_CERT_INVALID", 63: "BODY_LIMIT", 77: "HTTP_RUNTIME_INVALID", 92: "HTTP_PROTOCOL_INCOMPATIBLE", 95: "HTTP3_UNVERIFIED", 96: "HTTP3_UNVERIFIED" })[code] || "HTTP_TRANSFER_FAILED";113
}114
async function curlRequest(url, address, protocol, options, signal, runner = runProcess) {115
checkAbort(signal);116
if (!net.isIP(address)) throw new DiagnosticError("INVALID_IP");117
parseTarget(url.href);118
if (protocol === "3" && url.protocol !== "https:") return { status: "unavailable", code: "HTTP3_REQUIRES_HTTPS" };119
const capability = options.capability || await getCurlCapability(runner, signal);120
const client = { version: capability.version, http2: capability.http2, http3: capability.http3, tlsBackend: capability.tlsBackend, platform: capability.platform, arch: capability.arch };121
if (!capability.available) return { status: "unavailable", code: capability.code, capability: client };122
const port = Number(url.port || (url.protocol === "https:" ? 443 : 80)), host = url.hostname.replace(/^\[|\]$/g, "");123
const args = ["--disable", "--silent", "--show-error", { "1.1": "--http1.1", "2": "--http2-prior-knowledge", "3": "--http3-only" }[protocol],124
"--ipv" + net.isIP(address), "--noproxy", "*", "--proto", "=http,https", "--cacert", options.caPath || capability.caPath,125
"--max-time", String(options.timeoutMs / 1000), "--connect-timeout", String(options.timeoutMs / 1000), "--max-filesize", String(MAX_BODY),126
"--output", "-", "--dump-header", "-", "--header", "Accept-Encoding: " + (options.encoding || "identity"), "--user-agent", "CH-J-Server-Diagnostics/1",127
...(net.isIP(host) ? [] : ["--resolve", `${host}:${port}:${net.isIP(address) === 6 ? `[${address}]` : address}`]),128
"--write-out", "\nCHJ_METRICS:%{json}", "--url", url.href];129
let result;130
try { result = await runner(capability.executable, args, { signal, timeoutMs: options.timeoutMs + 1000, maxBytes: MAX_BODY + 131072, env: runtimeEnvironment() }); }131
catch (error) { if (signal?.aborted) throw new DiagnosticError("CANCELLED"); if (error.code === "TIMEOUT" && protocol === "3") return { status: "error", code: "HTTP3_UDP_TIMEOUT", capability: client }; throw error; }132
if (result.code !== 0) return { status: "error", code: curlFailure(result.code, protocol), curlCode: result.code, capability: client };133
const output = result.stdout, marker = output.lastIndexOf("\nCHJ_METRICS:");134
if (marker < 0) throw new DiagnosticError("HTTP_INVALID_RESULT");135
let metrics; try { metrics = JSON.parse(output.subarray(marker + 13).toString()); } catch { throw new DiagnosticError("HTTP_INVALID_RESULT"); }136
if (String(metrics.http_version) !== protocol) return { status: "unsupported", code: protocol === "3" ? "HTTP3_FALLBACK_REJECTED" : "HTTP_PROTOCOL_INCOMPATIBLE", negotiated: String(metrics.http_version), capability: client };137
const required = ["time_total", "time_starttransfer", "time_appconnect", "size_download", "speed_download", "response_code", "remote_port"];138
if (required.some((key) => !Number.isFinite(metrics[key]) || metrics[key] < 0) || metrics.response_code < 200) throw new DiagnosticError("HTTP_INVALID_RESULT");139
if (url.protocol === "https:" && metrics.ssl_verify_result !== 0) throw new DiagnosticError("TLS_CERT_INVALID");140
if (metrics.size_download > MAX_BODY) throw new DiagnosticError("BODY_LIMIT");141
let head, cursor = 0, bodyStart;142
for (let index = 0; index < 8; index++) {143
const end = output.indexOf("\r\n\r\n", cursor);144
if (end < 0 || end > 32768 || end >= marker) throw new DiagnosticError("HTTP_INVALID_RESULT");145
const candidate = output.subarray(cursor, end).toString(), status = candidate.match(/^HTTP\/(1\.1|2|3)(?:\.0)?\s+(\d{3})\b/);146
if (!status) throw new DiagnosticError("HTTP_INVALID_RESULT");147
if (Number(status[2]) >= 200) { if (Number(status[2]) !== metrics.response_code) throw new DiagnosticError("HTTP_INVALID_RESULT"); head = candidate; bodyStart = end + 4; break; }148
cursor = end + 4;149
}150
if (!head) throw new DiagnosticError("HTTP_INVALID_RESULT");151
const headers = {};152
head.split("\r\n").slice(1).forEach((line) => { const at = line.indexOf(":"); if (at > 0) headers[line.slice(0, at).toLowerCase()] = line.slice(at + 1).trim(); });153
const body = output.subarray(bodyStart, marker);154
return { status: "success", code: protocol === "3" ? "HTTP3_CONFIRMED" : undefined, negotiated: protocol, alpn: { "1.1": "http/1.1", "2": "h2", "3": "h3" }[protocol], statusCode: metrics.response_code,155
serverIp: metrics.remote_ip, port: metrics.remote_port, headers: safeHeaders(headers), altSvc: headers["alt-svc"] || null,156
downloadedBytes: metrics.size_download, contentType: metrics.content_type, contentLength: /^\d+$/.test(headers["content-length"]) ? Number(headers["content-length"]) : null,157
_body: body, bodyHash: crypto.createHash("sha256").update(body).digest("hex"), capability: client,158
tls: url.protocol === "https:" ? { verified: true, version: protocol === "3" ? "TLSv1.3" : null, backend: capability.tlsBackend } : null,159
reused: false, reuseReason: "Independent bundled-client process for every sample; connection reuse is not measured.",160
timings: { dnsMs: null, tcpMs: protocol === "3" ? null : Number(metrics.time_connect) * 1000 || null,161
tlsMs: protocol === "3" || url.protocol !== "https:" ? null : (metrics.time_appconnect - (metrics.time_connect || 0)) * 1000,162
quicHandshakeMs: protocol === "3" ? metrics.time_appconnect * 1000 : null, ttfbMs: metrics.time_starttransfer * 1000,163
downloadMs: (metrics.time_total - metrics.time_starttransfer) * 1000, totalMs: metrics.time_total * 1000 }, bytesPerSecond: metrics.speed_download };164
}165
async function http3Request(url, address, options, signal, runner = runProcess) { return curlRequest(url, address, "3", options, signal, runner); }166
function decodeBody(body, encoding, signal) {167
checkAbort(signal);168
const methods = { gzip: "createGunzip", deflate: "createInflate", br: "createBrotliDecompress", zstd: "createZstdDecompress" };169
if (encoding === "identity") return Promise.resolve(body);170
if (typeof zlib[methods[encoding]] !== "function") throw new DiagnosticError("COMPRESSION_CLIENT_UNAVAILABLE");171
return new Promise((resolve, reject) => {172
const decoder = zlib[methods[encoding]](); const pieces = []; let size = 0;173
const abort = () => decoder.destroy(new DiagnosticError("CANCELLED")); signal?.addEventListener("abort", abort, { once: true });174
decoder.on("data", (piece) => { size += piece.length; if (size > MAX_BODY * 4) decoder.destroy(new DiagnosticError("DECOMPRESSION_LIMIT")); else pieces.push(piece); });175
decoder.once("error", (error) => { signal?.removeEventListener("abort", abort); reject(error); });176
decoder.once("end", () => { signal?.removeEventListener("abort", abort); resolve(Buffer.concat(pieces)); }); decoder.end(body);177
});178
}179
class HttpDiagnostics {180
constructor(dnsService, options = {}) { this.dns = dnsService; this.ca = options.ca; this.h3 = options.h3 || ((url, ip, settings, signal) => this.curlRequest(url, ip, "3", { ...settings, signal })); this.native = Boolean(options.native); this.caPath = options.caPath; }181
async request(target, address, protocol, options, signal, resources, encoding = "identity") {182
let url = new URL(target.url); if (url.protocol === "ws:") url.protocol = "http:"; if (url.protocol === "wss:") url.protocol = "https:";183
const visited = new Set(), redirects = []; const started = now(); let redirectMs = 0, dnsMs = 0, hadDns = false;184
for (let hop = 0; hop <= 8; hop++) {185
checkAbort(signal); parseTarget(url.href);186
if (visited.has(url.href)) throw new DiagnosticError("REDIRECT_LOOP"); visited.add(url.href);187
const host = url.hostname.replace(/^\[|\]$/g, ""); let ip = address;188
if (host !== target.host || !options.selectedIp && !net.isIP(host)) {189
const dnsStart = now(); const list = await this.dns.resolve(host, { ...options, mode: net.isIP(address) === 6 ? "ipv6" : "ipv4" }, signal); dnsMs += now() - dnsStart; hadDns = true;190
ip = host === target.host && list.some((entry) => entry.address === address) ? address : list[0].address;191
}192
const beforeRequestElapsed = now() - started;193
const result = protocol === "3" ? await this.h3(url, ip, options, signal) : await (this.native ? nativeRequest : this.curlRequest.bind(this))(url, ip, protocol, { ...options, signal, encoding, ca: this.ca, agent: options.warm ? url.protocol === "https:" ? resources.https : resources.http : null, sessionCache: options.warm ? resources.h2 : null });194
if (result.status !== "success") return { ...result, redirects };195
if ([301, 302, 303, 307, 308].includes(result.statusCode) && result.headers.location) {196
if (hop === 8) throw new DiagnosticError("REDIRECT_LIMIT");197
const next = new URL(result.headers.location, url); parseTarget(next.href);198
redirects.push({ from: url.href, to: next.href, statusCode: result.statusCode, crossDomain: next.hostname !== url.hostname, downgrade: url.protocol === "https:" && next.protocol === "http:" });199
redirectMs += result.timings.totalMs; url = next; continue;200
}201
return { ...result, url: url.href, redirects, redirectCount: redirects.length, securityHeaders: securityHeaders(result.headers, url.protocol === "https:"),202
timings: { ...result.timings, finalResponseTtfbMs: result.timings.ttfbMs, ttfbMs: result.timings.ttfbMs === null ? null : beforeRequestElapsed + result.timings.ttfbMs, dnsMs: hadDns ? dnsMs : null, redirectMs, totalMs: now() - started }, dnsReason: hadDns ? null : "Pinned IP or IP literal; no DNS operation in this request." };203
}204
}205
async curlRequest(url, ip, protocol, options) {206
let temp;207
try {208
let caPath = this.caPath;209
if (this.ca) {210
temp = require("node:fs").mkdtempSync(require("node:path").join(require("node:os").tmpdir(), "chj-test-ca-"));211
caPath = require("node:path").join(temp, "ca.pem"); require("node:fs").writeFileSync(caPath, this.ca, { mode: 0o600 });212
}213
return await curlRequest(url, ip, protocol, { ...options, caPath }, options.signal);214
} finally { if (temp) require("node:fs").rmSync(temp, { recursive: true, force: true }); }215
}216
resources() { return { http: new http.Agent({ keepAlive: true, maxSockets: 1 }), https: new https.Agent({ keepAlive: true, maxSockets: 1 }), h2: new Map() }; }217
dispose(resources) { resources.http.destroy(); resources.https.destroy(); resources.h2.forEach((session) => session.destroy()); }218
async run(target, address, options, signal, progress) {219
const resources = this.resources(), comparisons = [];220
try {221
const targets = [target];222
if (options.compareSchemes) { const url = new URL(target.url); url.protocol = url.protocol === "http:" ? "https:" : "http:"; url.port = ""; targets.push(parseTarget(url.href)); }223
for (const tested of targets) for (const protocol of options.protocols) {224
const samples = [];225
for (let iteration = 0; iteration < options.repetitions; iteration++) {226
checkAbort(signal); let sample;227
try { sample = await this.request(tested, address, protocol, options, signal, resources); }228
catch (error) { if (signal.aborted) throw error; sample = errorResult(error); if (["HTTP2_NOT_NEGOTIATED", "ERR_HTTP2_ERROR", "ERR_HTTP2_SESSION_ERROR"].includes(error.code)) sample.status = "unsupported"; }229
delete sample._body;230
samples.push({ iteration, ...sample }); progress({ kind: "http-sample", protocol, address, scheme: new URL(tested.url).protocol, iteration, ...sample });231
if (["unavailable", "unsupported"].includes(sample.status)) break;232
}233
comparisons.push({ protocol, url: tested.url, address, connection: this.native && options.warm ? "warm-if-reused" : "cold", samples, statistics: stats(samples.map((s) => s.timings?.totalMs)), errorRate: 100 * samples.filter((s) => s.status !== "success").length / samples.length });234
}235
return { status: comparisons.some((c) => c.samples.some((s) => s.status === "success")) ? "success" : "warning", comparisons, note: "Network/server request timings; no page rendering or Lighthouse score is measured." };236
} finally { this.dispose(resources); }237
}238
async compression(target, address, options, signal, progress) {239
const resources = this.resources(), results = []; let identity;240
try {241
for (const encoding of ["identity", "gzip", "deflate", "br", "zstd"]) {242
checkAbort(signal); let result;243
if (encoding === "zstd" && !zlib.createZstdDecompress) { results.push({ encoding, status: "unavailable", code: "COMPRESSION_CLIENT_UNAVAILABLE" }); continue; }244
try {245
const response = await this.request(target, address, "1.1", { ...options, warm: false }, signal, resources, encoding);246
const actual = String(response.headers?.["content-encoding"] || "identity").toLowerCase().trim();247
if (response.status !== "success") result = response;248
else if (actual !== encoding) result = { status: "unsupported", actualEncoding: actual, downloadedBytes: response.downloadedBytes, reason: "Server did not use the requested content encoding." };249
else {250
const decoded = await decodeBody(response._body, encoding, signal);251
const decodedHash = crypto.createHash("sha256").update(decoded).digest("hex");252
if (encoding === "identity") identity = { hash: decodedHash, bytes: decoded.length, url: response.url, statusCode: response.statusCode };253
const comparable = identity && identity.hash === decodedHash && identity.url === response.url && identity.statusCode === response.statusCode;254
result = { status: "success", actualEncoding: actual, downloadedBytes: response.downloadedBytes, decodedBytes: decoded.length, decodedHash, comparable: Boolean(comparable), ratio: comparable && identity.bytes > 0 ? response.downloadedBytes / identity.bytes : null, statusCode: response.statusCode };255
}256
} catch (error) { if (signal.aborted) throw error; result = errorResult(error); }257
results.push({ encoding, ...result }); progress({ kind: "compression", address, encoding, ...result });258
}259
return { status: results.some((r) => r.encoding === "br" && r.status === "success") ? "success" : "warning", results, note: "Compression is confirmed by Content-Encoding and bounded decompression. Ratios require identical decoded content." };260
} finally { this.dispose(resources); }261
}262
}263
module.exports = { HttpDiagnostics, nativeRequest, http3Request, curlRequest, getCurlCapability, safeHeaders, securityHeaders, decodeBody, MAX_BODY };SHA-256
3125caaf30865d74cb78a51379f75c58971bdfed18f411781dbfd5fd73b4530bCH-J Proprietary Software License 1.14 · Šaltinis pateiktas pagal CH-J Proprietary Software License 1.14. Jo prieinamumas nekeičia licencijos sąlygų ir nesuteikia papildomų teisių.
Archyvas SHA-256
3df08cf5e899262a6ce92d7ec2caf5c42b683f1f2b57055662b845ff7dde67a8