UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

483 lines • 18.5 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // The lines that open every file curl -c writes (curl 8.14.1); readers look // for the first. export const JAR_HEADER = '# Netscape HTTP Cookie File\n' + '# https://curl.se/docs/http-cookies.html\n' + '# This file was generated by libcurl! Edit at your own risk.\n\n'; // What a cookie file puts before the domain of an HttpOnly cookie. export const HTTP_ONLY_PREFIX = '#HttpOnly_'; // No cookie outlives 400 days from its arrival (RFC 6265bis); curl rounds // the capped time up to a whole minute. export const MAX_LIFETIME = 400 * 24 * 60 * 60; // The hosts that take and get a Secure cookie over plain http. export const SECURE_HOSTS = new Set(['localhost', '127.0.0.1', '::1']); const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; // RFC 822's zones, hours east of UTC. export const ZONES = { gmt: 0, ut: 0, utc: 0, z: 0, est: -5, edt: -4, cst: -6, cdt: -5, mst: -7, mdt: -6, pst: -8, pdt: -7, }; const DATE_TOKEN = /[+-][0-9]{4}(?![0-9])|[0-9]{1,2}:[0-9]{1,2}(?::[0-9]{1,2})?|[A-Za-z]+|[0-9]+/g; const IPV4 = /^[0-9]{1,3}(?:\.[0-9]{1,3}){3}$/; // The largest expiry a cookie file may give. const INT64_MAX = 2n ** 63n - 1n; export function epochNow() { return Math.floor(Date.now() / 1000); } // Space and tab off both ends, the blanks RFC 6265 strips. // A control byte, which no cookie name or value may hold (curl 8.14.1). function hasControl(text) { for (let i = 0; i < text.length; i += 1) { const code = text.charCodeAt(i); if (code < 0x20 || code === 0x7f) return true; } return false; } function trimWs(text) { let start = 0; let end = text.length; while (start < end && (text[start] === ' ' || text[start] === '\t')) start += 1; while (end > start && (text[end - 1] === ' ' || text[end - 1] === '\t')) end -= 1; return text.slice(start, end); } /** * The seconds since the epoch an Expires attribute names, null when it does * not parse. * * The forms curl reads: a day of the month, a month name and a year in any * order, a time (midnight without one), and a zone, RFC 822's names or a * `+hhmm` offset of at most 1400, the first one found (UTC without one). A * sign before a larger number only separates it (`01-Jan-1970`), and a * number with no field left to fill fails the date. A two-digit year above * 70 is in the 1900s, the rest in the 2000s, and a date before 1970 does not * parse (curl 8.14.1). Zone names past RFC 822's are a deliberate gap: curl * knows more of them. */ export function parseDate(text) { let day = null; let month = null; let year = null; let time = null; let offset = 0; let zoned = false; for (const match of text.matchAll(DATE_TOKEN)) { let token = match[0]; if (token.startsWith('+') || token.startsWith('-')) { const hhmm = Number(token.slice(1)); if (hhmm <= 1400) { if (!zoned) { const sign = token.startsWith('-') ? -1 : 1; offset = sign * (Math.floor(hhmm / 100) * 3600 + (hhmm % 100) * 60); zoned = true; } continue; } token = token.slice(1); } if (token.includes(':')) { if (time !== null) continue; const [h = 0, m = 0, s = 0] = token.split(':').map(Number); if (h > 23 || m > 59 || s > 59) return null; time = [h, m, s]; } else if (/^[A-Za-z]/.test(token)) { const lower = token.toLowerCase(); const named = MONTHS.indexOf(lower.slice(0, 3)); if (named >= 0 && month === null && lower.length >= 3) { month = named; } else if (lower in ZONES && !zoned) { offset = (ZONES[lower] ?? 0) * 3600; zoned = true; } } else if (token.length === 4) { if (year !== null) return null; year = Number(token); } else if (token.length <= 2) { const n = Number(token); if (day === null) day = n; else if (year === null) year = n <= 70 ? 2000 + n : 1900 + n; else return null; } else { return null; } } if (day === null || month === null || year === null || day < 1 || day > 31) return null; if (year < 1970) return null; const [h, m, s] = time ?? [0, 0, 0]; const epoch = Date.UTC(year, month, day, h, m, s) / 1000 - offset; return epoch < 0 ? null : epoch; } /** The request a URL makes, as cookie matching sees it. */ function target(url) { let parsed; try { parsed = new URL(url); } catch { return null; } const host = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase(); return { host, path: parsed.pathname === '' ? '/' : parsed.pathname, secure: parsed.protocol === 'https:' || SECURE_HOSTS.has(host), }; } function isIp(host) { return IPV4.test(host) || host.includes(':'); } /** * The path a cookie set without one takes: the request's up to and including * its last slash. curl keeps that slash, which RFC 6265 drops, and the * matching below ignores it. */ export function defaultPath(path) { const cut = path.lastIndexOf('/'); return cut <= 0 ? '/' : path.slice(0, cut + 1); } // The path a cookie matches with: one that does not start with a slash // covers everything, and a trailing slash is dropped (curl 8.14.1). function pathKey(cookiePath) { if (cookiePath?.startsWith('/') !== true) return '/'; return cookiePath.length > 1 && cookiePath.endsWith('/') ? cookiePath.slice(0, -1) : cookiePath; } /** * Whether a cookie's path covers a request's: RFC 6265's rule, the same path * or one below it, over the path the cookie matches with. */ export function pathMatches(cookiePath, path) { const own = pathKey(cookiePath); if (path === own) return true; return path.startsWith(own) && (own.endsWith('/') || path[own.length] === '/'); } function domainMatches(cookie, host) { if (cookie.domain === null) return true; const domain = cookie.domain.toLowerCase(); return host === domain || (cookie.tailmatch && host.endsWith(`.${domain}`)); } function sameCookie(a, b) { return (a.name === b.name && (a.domain ?? '').toLowerCase() === (b.domain ?? '').toLowerCase() && pathKey(a.path) === pathKey(b.path)); } /** * Whether two URLs share an origin: scheme, host and port. A Cookie header * the line typed goes only to the first request's origin (curl 8.14.1). */ export function sameOrigin(a, b) { try { return new URL(a).origin === new URL(b).origin; } catch { return false; } } /** * The Cookie value a request carries: the jar's part, then the typed one. * A deliberate divergence: curl sends the two as separate Cookie lines, and * one line joined with `; ` reads the same to a server (node joins them so). */ export function cookieValue(part, typed) { const pieces = [part, typed].filter((piece) => piece !== null && piece !== ''); return pieces.length === 0 ? null : pieces.join('; '); } /** * curl's cookie engine for one invocation: what -b loads, what each response * sets, what each request sends, and the file -c writes. * * Matching follows curl 8.14.1 where it parts from RFC 6265: a Secure cookie * also rides plain http to localhost, 127.0.0.1 and ::1, and a request sends * longer paths first, then longer names, then newer cookies. One deliberate * gap: curl refuses a Domain that is a public suffix (co.uk) through the * public suffix list, whose license mirage cannot ship, so only a suffix of * one label (com) is refused here. A host-only cookie's domain is the host * lowercase, as the URL parser hands it over, where curl keeps it as typed. */ export class CookieJar { engine; literal; now; // Oldest first. cookies = []; // The jar's part of each request's Cookie header, in the order sent; // curl -v shows it per hop. sent = []; arrivals = 0; constructor( // Whether the jar keeps what responses set: on once the line names a // cookie file or -c, off for literal -b data alone (curl 8.14.1). engine, // The literal -b data, sent to every host after the jar's cookies. literal = null, now = epochNow) { this.engine = engine; this.literal = literal; this.now = now; } keep(cookie, expired) { const index = this.cookies.findIndex((other) => sameCookie(other, cookie)); if (expired) { if (index >= 0) this.cookies.splice(index, 1); return; } if (index >= 0) { const prior = this.cookies[index]; this.cookies[index] = { ...cookie, created: prior?.created ?? cookie.created }; return; } this.arrivals += 1; this.cookies.push({ ...cookie, created: this.arrivals }); } /** * Add one Set-Cookie value. `url` is the request it answered, null for a * line a file gave, which no host, path or scheme constrains. */ add(header, url) { const request = url === null ? null : target(url); if (url !== null && request === null) return; const [pair = '', ...attributes] = header.split(';'); const eq = pair.indexOf('='); if (eq < 0) return; const name = trimWs(pair.slice(0, eq)); const value = trimWs(pair.slice(eq + 1)); if (hasControl(name) || hasControl(value)) return; let expiresAt = null; let maxAge = null; let domainAttr = null; let pathAttr = null; let secure = false; let httpOnly = false; for (const attribute of attributes) { const sep = attribute.indexOf('='); const key = trimWs(sep < 0 ? attribute : attribute.slice(0, sep)).toLowerCase(); const raw = sep < 0 ? '' : trimWs(attribute.slice(sep + 1)); if (key === 'expires') { expiresAt = parseDate(raw) ?? expiresAt; } else if (key === 'max-age') { // curl drops a cookie whose Max-Age does not start with a digit or a // minus, and reads the digits that lead one that does; past twelve // significant ones only the sign matters. const digits = /^(-?)([0-9]+)/.exec(raw); if (digits === null) return; const [, minus = '', count = ''] = digits; const significant = count.replace(/^0+(?=[0-9])/, ''); maxAge = significant.length > 12 ? minus === '' ? MAX_LIFETIME + 1 : -1 : Number(minus + significant); } else if (key === 'domain') { if (raw !== '') domainAttr = raw.replace(/^\./, ''); } else if (key === 'path') { pathAttr = raw; } else if (key === 'secure') { secure = true; } else if (key === 'httponly') { httpOnly = true; } } const now = this.now(); let expires = 0; let expired = false; if (maxAge !== null) { expired = maxAge <= 0; expires = expired ? 0 : now + Math.min(maxAge, MAX_LIFETIME + 1); } else if (expiresAt !== null) { expired = expiresAt <= now; expires = expiresAt; } if (expires > now + MAX_LIFETIME) expires = Math.ceil((now + MAX_LIFETIME) / 60) * 60; let domain = domainAttr; let tailmatch = domainAttr !== null; let path = pathAttr; if (request !== null) { path = pathAttr ?? defaultPath(request.path); if (domainAttr === null) { domain = request.host; } else { const wanted = domainAttr.toLowerCase(); if (isIp(request.host)) { if (wanted !== request.host) return; domain = request.host; tailmatch = false; } else if (wanted === request.host || request.host.endsWith(`.${wanted}`)) { if (!wanted.includes('.') && wanted !== request.host) return; } else { return; } } if (secure && !request.secure) return; if (name.startsWith('__Secure-') && !secure) return; if (name.startsWith('__Host-') && (!secure || tailmatch || path !== '/')) return; // Plain http may neither replace nor delete a Secure cookie of the // same name and domain whose path covers this one's (RFC 6265bis, // curl 8.14.1). const key = (domain ?? '').toLowerCase(); const own = pathKey(path); if (!request.secure && this.cookies.some((c) => c.secure && (c.expires === 0 || c.expires > now) && c.name === name && (c.domain ?? '').toLowerCase() === key && pathMatches(c.path, own))) { return; } } this.keep({ name, value, domain, tailmatch, path, secure, httpOnly, expires, created: 0 }, expired); } /** Keep what one response to `url` set. */ store(url, headers) { if (!this.engine) return; for (const header of headers) this.add(header, url); } /** * Load a cookie file: curl's tab-separated lines, `#HttpOnly_` marking a * domain, or `Set-Cookie:` header lines. A line that does not parse, one * whose expiry passes 64 bits, and a cookie already expired, are skipped * (curl 8.14.1). An expiry past 2^53 loses its low digits here, which * Python keeps: no date is that far out. */ load(text) { for (const raw of text.split('\n')) { let line = raw.endsWith('\r') ? raw.slice(0, -1) : raw; if (/^set-cookie:/i.test(line)) { this.add(line.slice('set-cookie:'.length).trimStart(), null); continue; } let httpOnly = false; if (line.startsWith(HTTP_ONLY_PREFIX)) { httpOnly = true; line = line.slice(HTTP_ONLY_PREFIX.length); } else if (line === '' || line.startsWith('#')) { continue; } const fields = line.split('\t'); if (fields.length === 6) fields.push(''); if (fields.length !== 7) continue; const [domain = '', tail = '', path = '', secure = '', expiry = '', name = '', value = ''] = fields; const digits = /^[0-9]+/.exec(expiry); const count = digits?.[0].replace(/^0+(?=[0-9])/, '') ?? null; if (count === null || count.length > 19 || BigInt(count) > INT64_MAX) continue; const expires = Number(count); if (expires !== 0 && expires <= this.now()) continue; this.keep({ name, value, domain: domain.replace(/^\./, ''), tailmatch: tail.toUpperCase() === 'TRUE', path, secure: secure.toUpperCase() === 'TRUE', httpOnly, expires, created: 0, }, false); } } /** The jar's part of the Cookie header a request to `url` sends. */ header(url) { const request = target(url); const now = this.now(); const pairs = !this.engine || request === null ? [] : this.cookies .filter((c) => (c.expires === 0 || c.expires > now) && domainMatches(c, request.host) && pathMatches(c.path, request.path) && (!c.secure || request.secure)) .sort((a, b) => (b.path?.length ?? 0) - (a.path?.length ?? 0) || b.name.length - a.name.length || b.created - a.created) .map((c) => `${c.name}=${c.value}`); if (this.literal !== null) pairs.push(this.literal); const value = pairs.length === 0 ? null : pairs.join('; '); this.sent.push(value); return value; } /** The file -c writes: every live cookie with a domain, newest first. */ render() { const now = this.now(); const lines = this.cookies .filter((c) => c.domain !== null && (c.expires === 0 || c.expires > now)) .sort((a, b) => b.created - a.created) .map((c) => [ `${c.httpOnly ? HTTP_ONLY_PREFIX : ''}${c.tailmatch ? '.' : ''}${c.domain ?? ''}`, c.tailmatch ? 'TRUE' : 'FALSE', c.path ?? '/', c.secure ? 'TRUE' : 'FALSE', String(c.expires), c.name, c.value, ].join('\t')); return JAR_HEADER + lines.map((line) => `${line}\n`).join(''); } } //# sourceMappingURL=cookie.js.map