diff --git a/.github/workflows/test-tag-paths-map.json b/.github/workflows/test-tag-paths-map.json index 51838c4c8e6..fc92a46fc31 100644 --- a/.github/workflows/test-tag-paths-map.json +++ b/.github/workflows/test-tag-paths-map.json @@ -128,6 +128,7 @@ "src/vs/platform/extensions/common/positronExtensionValidator.ts": ["@:extensions"], "src/vs/platform/positronActionBar/": [], "src/vs/platform/positronAiProvider/": ["@:assistant"], + "src/vs/platform/positronDocs/": [], "src/vs/platform/positronHeadlessLanguageModel/": ["@:positron-notebooks"], "src/vs/platform/positronIdleTracking/": [], "src/vs/platform/positronMemoryUsage/": ["@:variables"], diff --git a/src/vs/platform/positronDocs/common/positronDocsBundle.ts b/src/vs/platform/positronDocs/common/positronDocsBundle.ts new file mode 100644 index 00000000000..4df4906b035 --- /dev/null +++ b/src/vs/platform/positronDocs/common/positronDocsBundle.ts @@ -0,0 +1,202 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { formatPositronVersion } from '../../extensionManagement/common/positronGalleryTelemetry.js'; + +/** + * The only bundle layout this build understands. Bumped by the website + * pipeline only when a reader written against the old layout would get a + * wrong answer. Schema 1 is defined as including "llms.txt uses + * bundle-relative paths". + */ +export const DOCS_BUNDLE_SCHEMA = 1; + +/** Name of the persisted state file inside the cache root. */ +export const DOCS_STATE_FILENAME = 'state.json'; + +/** Files a valid extracted bundle must contain. */ +export const DOCS_MANIFEST_FILENAME = 'bundle.json'; +export const DOCS_INDEX_FILENAME = 'llms.txt'; + +/** + * Refuse anything larger. This is a disk-fill and expansion guard against a + * wrong or hostile object, not a size expectation: the digest is verified only + * after the bytes land, so something has to bound the write. Deliberately far + * above the real bundle (about 150KB zipped, 655KB across ~90 files) because + * the cap is baked into a shipped client while the bundle is published on the + * website's cadence - a client that trips this silently falls back to web docs + * and cannot be fixed after the fact. The publish pipeline enforces the real + * size budget, where exceeding it fails a build someone can react to. + */ +export const DOCS_MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024; + +export type DocsProfile = 'positron' | 'workbench'; + +/** + * How the cached bundle relates to the running build. + * - `exact`: bundle version equals app version. Terminal; no further network. + * - `fallback`: exact was not published (yet). Re-attempts exact every launch. + * - `latest-by-policy`: dailies and dev builds, where latest is the target. + */ +export type DocsResolution = 'exact' | 'fallback' | 'latest-by-policy'; + +/** The shape of bundle.json, as produced by the website pipeline. */ +export interface IDocsBundleManifest { + readonly schema: number; + readonly profile: string; + readonly version: string; + readonly generated: string; + readonly docsBaseUrl: string; + readonly fileCount: number; +} + +/** Persisted cache state. Written atomically; never partially trusted. */ +export interface IDocsCacheState { + readonly schema: number; + /** Version of the bundle actually on disk; also its directory name. */ + readonly version: string; + /** Version the app asked for, which may differ while in `fallback`. */ + readonly requestedVersion: string; + readonly resolution: DocsResolution; + /** + * Profile the bundle was fetched for. Diagnostic only, like `sha256`: it is + * never compared on read. A single install's profile is fixed at build time, + * so a cache directory cannot legitimately hold two, and `_readCached` takes + * the profile it reports from the extracted manifest instead. + */ + readonly profile: string; + /** + * Digest verified before extraction. Diagnostic only: recorded once and + * never recomputed, since the zip is deleted after extracting. + */ + readonly sha256: string; + readonly etag?: string; + readonly sourceUrl: string; + readonly fetchedAt: number; + readonly lastAttemptAt: number; + readonly lastFailureAt?: number; + readonly lastError?: string; +} + +export interface IDocsBundleRequest { + /** `initData.quality`: 'releases', 'dailies', or undefined in dev builds. */ + readonly quality: string | undefined; + readonly positronVersion: string; + readonly positronBuildNumber: number; + readonly profile: DocsProfile; + readonly baseUrl: string; +} + +export interface IResolvedBundle { + /** `` for the exact form, the literal 'latest' for the alias. */ + readonly version: string; + readonly form: 'exact' | 'latest'; + readonly zipUrl: string; + readonly sha256Url: string; +} + +export interface IResolvedBundleRequest { + readonly exact: IResolvedBundle; + readonly latest: IResolvedBundle; + /** + * True for release builds, which target their exact version and fall back + * to latest only until it publishes. False for dailies and dev builds, + * where latest is the intended target. + */ + readonly wantsExact: boolean; +} + +function bundleBaseName(profile: DocsProfile): string { + return profile === 'workbench' ? 'positron-workbench-llms' : 'positron-llms'; +} + +function bundleUrls(baseUrl: string, profile: DocsProfile, version: string, form: 'exact' | 'latest'): IResolvedBundle { + const root = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; + const zipUrl = `${root}/${bundleBaseName(profile)}-${version}.zip`; + return { version, form, zipUrl, sha256Url: `${zipUrl}.sha256sum` }; +} + +/** + * Work out which bundle URLs this build should ask for. + * + * Release builds target their exact version so a shipped build reads the docs + * it shipped with; everything else targets the mutable `latest` alias. Dev + * builds landing on `latest` is deliberate - it makes the feature exercisable + * locally and in PRs without waiting on a release. + */ +export function resolveBundleRequest(request: IDocsBundleRequest): IResolvedBundleRequest { + const version = formatPositronVersion(request.positronVersion, request.positronBuildNumber); + return { + exact: bundleUrls(request.baseUrl, request.profile, version, 'exact'), + latest: bundleUrls(request.baseUrl, request.profile, 'latest', 'latest'), + wantsExact: request.quality === 'releases', + }; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +/** + * Parse bundle.json, rejecting anything this build cannot read. + * + * Rejecting is deliberate over guessing: dev and daily builds fetch the mutable + * `latest` alias, so "an app from three months ago is handed a bundle from + * today's pipeline" is a normal runtime state. A misparse surfaces later as + * wrong docs content; a rejection surfaces immediately as web fallback. + */ +export function parseManifest(raw: string): IDocsBundleManifest | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return undefined; + } + if (typeof parsed !== 'object' || parsed === null) { + return undefined; + } + const candidate = parsed as Partial; + if (candidate.schema !== DOCS_BUNDLE_SCHEMA) { + return undefined; + } + if (!isNonEmptyString(candidate.profile) || !isNonEmptyString(candidate.version) + || !isNonEmptyString(candidate.generated) || !isNonEmptyString(candidate.docsBaseUrl)) { + return undefined; + } + if (typeof candidate.fileCount !== 'number' || !Number.isInteger(candidate.fileCount) || candidate.fileCount <= 0) { + return undefined; + } + return { + schema: candidate.schema, + profile: candidate.profile, + version: candidate.version, + generated: candidate.generated, + docsBaseUrl: candidate.docsBaseUrl, + fileCount: candidate.fileCount, + }; +} + +const SHA256_HEX = /^[0-9a-f]{64}$/; + +/** + * Read the hex digest out of a `.sha256sum` checksum file. + * + * Accepts both `shasum -a 256` output (` `) and a bare digest. + * Returns undefined for anything else, including an HTML error page served in + * place of a missing object - which the caller must treat as a hard failure. + */ +export function parseDigestFile(raw: string): string | undefined { + const first = raw.trim().split(/\s+/)[0]?.toLowerCase(); + return first && SHA256_HEX.test(first) ? first : undefined; +} + +/** + * How long a hard failure (network, DNS, connection, 5xx, disk) suppresses the + * next attempt. This stops a persistent CDN or configuration problem turning + * into a per-launch request from every install at once. Deliberately does NOT + * apply to the 404 convergence check. + */ +export const DOCS_FAILURE_THROTTLE_MS = 60 * 60 * 1000; + diff --git a/src/vs/platform/positronDocs/common/positronDocsCache.ts b/src/vs/platform/positronDocs/common/positronDocsCache.ts new file mode 100644 index 00000000000..5e0e531a95a --- /dev/null +++ b/src/vs/platform/positronDocs/common/positronDocsCache.ts @@ -0,0 +1,482 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + DOCS_BUNDLE_SCHEMA, DOCS_FAILURE_THROTTLE_MS, DOCS_MAX_DOWNLOAD_BYTES, + DOCS_STATE_FILENAME, DocsResolution, + IDocsBundleManifest, IDocsBundleRequest, IDocsCacheState, IResolvedBundle, IResolvedBundleRequest, + parseDigestFile, resolveBundleRequest, +} from './positronDocsBundle.js'; +import { IDocsArchive, IDocsFileStore, IDocsHttpClient, IDocsLogger, ILocalDocs, joinDocsPath } from './positronDocsIO.js'; +import { guardEntryNames, validateExtractedBundle } from './positronDocsValidate.js'; + +// Names the artifact, not the app: every line here is already in a Positron log, +// and this code handles the slim LLM bundle specifically, which is a separate +// thing from the docs website these pages are published to. +const LOG_PREFIX = '[llm-docs]'; + +export interface IPositronDocsCacheOptions { + /** Cache root, e.g. `/User/positron-llm-docs`. */ + readonly rootPath: string; + readonly http: IDocsHttpClient; + readonly files: IDocsFileStore; + readonly archive: IDocsArchive; + readonly logger: IDocsLogger; + /** Injected so tests control time without faking timers. */ + readonly now: () => number; + /** Injected so temp and staging names are deterministic in tests. */ + readonly newId: () => string; +} + +/** Outcome of one download attempt. */ +type InstallOutcome = + | { readonly kind: 'installed'; readonly docs: ILocalDocs; readonly manifest: IDocsBundleManifest; readonly digest: string; readonly etag?: string } + | { readonly kind: 'not-modified' } + | { readonly kind: 'not-found' } + /** Verification or validation refused the payload. Never throttled. */ + | { readonly kind: 'rejected'; readonly reason: string } + /** Network, 5xx, or disk. Throttled across sessions (Task 7). */ + | { readonly kind: 'failed'; readonly reason: string }; + +function toLocalDocs(path: string, manifest: IDocsBundleManifest, isExactMatch: boolean): ILocalDocs { + return { + path, + schema: manifest.schema, + version: manifest.version, + profile: manifest.profile, + docsBaseUrl: manifest.docsBaseUrl, + isExactMatch, + }; +} + +/** + * Downloads, verifies, caches, and serves the slim docs bundle. + * + * The governing rule is that **a valid cached bundle is always served, + * whatever the current fetch attempt does**. A fetch can replace the served + * bundle on success but never withdraws one on failure, so `ensure()` returns + * undefined only when no valid cache exists. + */ +export class PositronDocsCache { + + private _inFlight: Promise | undefined; + private _attempted = false; + private _result: ILocalDocs | undefined; + /** Bumped by `invalidate()` so a mid-flight call is not overwritten. */ + private _generation = 0; + + constructor(private readonly _options: IPositronDocsCacheOptions) { } + + /** + * Resolve local docs, running at most one fetch at a time and at most one + * attempt per session. Concurrent callers join the in-flight operation + * rather than racing it. + */ + async ensure(request: IDocsBundleRequest): Promise { + if (this._attempted) { + return this._result; + } + if (this._inFlight) { + return await this._inFlight; + } + const generation = this._generation; + this._inFlight = this._ensureOnce(request); + try { + const result = await this._inFlight; + // Only close the session gate if no invalidate() landed while this + // attempt was in flight. Without the check, an `ai.enabled` flip + // during a download would be swallowed by the completing attempt and + // the caller would get no retry until the next launch. + if (generation === this._generation) { + this._result = result; + this._attempted = true; + } + return result; + } finally { + this._inFlight = undefined; + } + } + + /** + * Permit one more attempt this session. The only caller is the + * `ai.enabled` false-to-true transition, which is the single case the + * design allows to re-attempt without a relaunch. + * + * Safe to call while a fetch is in flight: that attempt still resolves for + * its own callers, but it no longer closes the gate. + */ + invalidate(): void { + this._attempted = false; + this._result = undefined; + this._generation++; + } + + private async _ensureOnce(request: IDocsBundleRequest): Promise { + const resolved = resolveBundleRequest(request); + const state = await this._readState(); + const cached = await this._readCached(state, resolved.exact.version); + + // Terminal: a release build already holding its own version never + // touches the network again. Both halves matter - `resolution` alone + // would keep an updated app pinned to its predecessor's docs. + if (cached && state?.resolution === 'exact' && state.version === resolved.exact.version) { + this._options.logger.info(`${LOG_PREFIX} exact cache hit for ${state.version}; no network`); + return cached; + } + + const lastFailureAt = state?.lastFailureAt; + if (lastFailureAt !== undefined && this._options.now() - lastFailureAt < DOCS_FAILURE_THROTTLE_MS) { + this._options.logger.info(`${LOG_PREFIX} skipping fetch; a hard failure is still inside the throttle window`); + return cached; + } + + return resolved.wantsExact + ? await this._ensureRelease(request, resolved, state, cached) + : await this._ensureLatest(request, resolved, state, cached); + } + + /** + * Release channel: target the exact version, fall back to latest until it + * publishes, and keep converging on every launch. + */ + private async _ensureRelease( + request: IDocsBundleRequest, + resolved: IResolvedBundleRequest, + state: IDocsCacheState | undefined, + cached: ILocalDocs | undefined, + ): Promise { + const { http, logger } = this._options; + + // This convergence check is never throttled. A HEAD is a few hundred + // bytes, and throttling it would let an install sit on a known-wrong + // docs version longer than the fallback policy intends. + let exactExists = false; + try { + const status = (await http.head(resolved.exact.zipUrl)).status; + exactExists = status === 200; + // 404 is the expected "not published yet" answer and stays quiet. + // Anything else means the CDN is unhealthy rather than not ready, and + // would otherwise fall through to the latest alias with no trace. + if (!exactExists && status !== 404) { + logger.info(`${LOG_PREFIX} unexpected HTTP ${status} from HEAD ${resolved.exact.zipUrl}`); + } + } catch (error) { + logger.info(`${LOG_PREFIX} exact HEAD failed for ${resolved.exact.zipUrl}: ${errorMessage(error)}`); + } + + if (exactExists) { + logger.info(`${LOG_PREFIX} fetching ${resolved.exact.zipUrl} (exact)`); + const outcome = await this._downloadAndInstall(resolved.exact, resolved.exact.version, undefined); + if (outcome.kind === 'installed') { + await this._recordInstall(outcome, request, resolved.exact.version, 'exact', resolved.exact, state?.version); + return outcome.docs; + } + this._logOutcome(outcome, resolved.exact); + } + + return await this._fetchLatest(request, resolved, state, cached, 'fallback'); + } + + /** Dailies and dev builds: latest is the intended target, not a fallback. */ + private async _ensureLatest( + request: IDocsBundleRequest, + resolved: IResolvedBundleRequest, + state: IDocsCacheState | undefined, + cached: ILocalDocs | undefined, + ): Promise { + return await this._fetchLatest(request, resolved, state, cached, 'latest-by-policy'); + } + + private async _fetchLatest( + request: IDocsBundleRequest, + resolved: IResolvedBundleRequest, + state: IDocsCacheState | undefined, + cached: ILocalDocs | undefined, + resolution: DocsResolution, + ): Promise { + // Logged before the request, not after: a download that hangs or is cut + // off leaves no other record of which URL this build was reaching for. + this._options.logger.info(`${LOG_PREFIX} fetching ${resolved.latest.zipUrl} (${resolution})`); + + // Conditional on the stored ETag. Using the `latest` alias rather than + // comparing versions keeps this monotonic without a version comparator. + const outcome = await this._downloadAndInstall(resolved.latest, resolved.exact.version, state?.etag); + if (outcome.kind === 'installed') { + await this._recordInstall(outcome, request, resolved.exact.version, resolution, resolved.latest, state?.version); + return outcome.docs; + } + this._logOutcome(outcome, resolved.latest); + + // Re-read state after the attempt rather than reusing the snapshot taken + // before it. Another window can install a bundle while this attempt is in + // flight, and both the state merge below and the value returned here would + // otherwise be built from a snapshot that no longer describes the disk: + // the merge would write back `version: ''` over the version that window + // just recorded, orphaning its bundle for every later session. + const current = await this._readState(); + if (outcome.kind === 'failed') { + await this._recordFailure(current, request, resolved.exact.version, resolution, outcome.reason); + } + if (outcome.kind === 'not-modified' && current) { + await this._touchState(current, resolution, resolved.exact.version); + } + + // Cache-present rule: a failed attempt never withdraws a served bundle. + return cached ?? await this._readCached(current, resolved.exact.version); + } + + private async _touchState(state: IDocsCacheState, resolution: DocsResolution, requestedVersion: string): Promise { + const now = this._options.now(); + await this._writeState({ ...state, resolution, requestedVersion, fetchedAt: now, lastAttemptAt: now }); + } + + private _logOutcome(outcome: InstallOutcome, target: IResolvedBundle): void { + const { logger } = this._options; + switch (outcome.kind) { + case 'rejected': + logger.warn(`${LOG_PREFIX} rejected bundle from ${target.zipUrl}: ${outcome.reason}`); + break; + case 'failed': + logger.info(`${LOG_PREFIX} fetch failed for ${target.zipUrl}: ${outcome.reason}`); + break; + case 'not-found': + logger.info(`${LOG_PREFIX} no bundle published at ${target.zipUrl}`); + break; + case 'not-modified': + logger.info(`${LOG_PREFIX} ${target.zipUrl} unchanged (304)`); + break; + } + } + + private async _recordInstall( + outcome: InstallOutcome & { kind: 'installed' }, + request: IDocsBundleRequest, + requestedVersion: string, + resolution: DocsResolution, + target: IResolvedBundle, + previousVersion: string | undefined, + ): Promise { + const now = this._options.now(); + await this._writeState({ + schema: DOCS_BUNDLE_SCHEMA, + version: outcome.manifest.version, + requestedVersion, + resolution, + profile: request.profile, + sha256: outcome.digest, + etag: outcome.etag, + sourceUrl: target.zipUrl, + fetchedAt: now, + lastAttemptAt: now, + }); + this._options.logger.info(`${LOG_PREFIX} installed ${outcome.manifest.version} from ${target.zipUrl}`); + // Targeted cleanup: this install supersedes exactly one directory, the + // version the previous state named. Deleting it by name is what keeps a + // dailies cache bounded without scanning the directory, so there is no + // sweep that could collide with another window's in-flight work. + // Best-effort: a cleanup failure must not discard a successful install. + if (previousVersion && previousVersion !== outcome.manifest.version) { + await this._safeDelete(joinDocsPath(this._options.rootPath, previousVersion)); + } + } + + /** + * Fetch, verify, extract, and swap in one bundle. + * + * Order matters: the zip is fetched first so a 404 reads as "not published + * yet" rather than as a verification failure, and the digest is checked + * before anything is extracted so a bad payload can never write to disk + * outside the staging directory. + */ + private async _downloadAndInstall(target: IResolvedBundle, exactVersion: string, etag: string | undefined): Promise { + const { archive, files, http, newId } = this._options; + const id = newId(); + const tmpZip = joinDocsPath(this._options.rootPath, `.tmp-${id}.zip`); + const staging = joinDocsPath(this._options.rootPath, `.staging-${id}`); + + try { + await files.mkdir(this._options.rootPath); + + const zip = await http.get(target.zipUrl, { etag, maxBytes: DOCS_MAX_DOWNLOAD_BYTES }); + if (zip.status === 304) { + return { kind: 'not-modified' }; + } + if (zip.status === 404) { + return { kind: 'not-found' }; + } + if (zip.status !== 200 || !zip.body) { + return { kind: 'failed', reason: `HTTP ${zip.status}` }; + } + + // A zip that cannot be verified is never extracted, even though + // that means a cold cache gets no local docs until the checksum file + // appears. Proceeding unverified would make the digest decorative. + const checksum = await http.get(target.sha256Url); + if (checksum.status !== 200 || !checksum.body) { + return { kind: 'rejected', reason: `checksum file unavailable (HTTP ${checksum.status})` }; + } + const expected = parseDigestFile(new TextDecoder().decode(checksum.body)); + if (!expected) { + return { kind: 'rejected', reason: 'checksum file does not hold a sha256 digest' }; + } + + await files.writeFile(tmpZip, zip.body); + const actual = await files.sha256(tmpZip); + if (actual !== expected) { + return { kind: 'rejected', reason: `digest mismatch (expected ${expected}, got ${actual})` }; + } + + try { + const offending = guardEntryNames(await archive.entryNames(tmpZip)); + if (offending) { + return { kind: 'rejected', reason: `archive entry escapes the target: ${offending}` }; + } + await archive.extract(tmpZip, staging); + } catch (error) { + return { kind: 'rejected', reason: `corrupt archive: ${errorMessage(error)}` }; + } + + const validation = await validateExtractedBundle(files, staging); + if (!validation.ok) { + return { kind: 'rejected', reason: `extracted bundle invalid (${validation.reason})` }; + } + + const docs = await this._swapIn(staging, validation.manifest, exactVersion, id); + return { kind: 'installed', docs, manifest: validation.manifest, digest: actual, etag: zip.etag }; + } catch (error) { + return { kind: 'failed', reason: errorMessage(error) }; + } finally { + await this._safeDelete(tmpZip); + await this._safeDelete(staging); + } + } + + /** + * Atomic swap. The rename means a killed process can never leave a + * half-populated version directory that later looks like a cache hit. + */ + private async _swapIn(staging: string, manifest: IDocsBundleManifest, exactVersion: string, id: string): Promise { + const { files } = this._options; + const target = joinDocsPath(this._options.rootPath, manifest.version); + if (await files.exists(target)) { + // Same version is already on disk but was not usable, or we would + // not have downloaded. Move it aside first so the recorded path + // never points at a directory that does not exist. + const stale = joinDocsPath(this._options.rootPath, `.stale-${id}`); + await files.rename(target, stale); + await files.rename(staging, target); + await this._safeDelete(stale); + } else { + await files.rename(staging, target); + } + return toLocalDocs(target, manifest, manifest.version === exactVersion); + } + + private async _readState(): Promise { + const path = joinDocsPath(this._options.rootPath, DOCS_STATE_FILENAME); + if (!await this._options.files.exists(path)) { + return undefined; + } + try { + const parsed = JSON.parse(await this._options.files.readFile(path)) as IDocsCacheState; + return typeof parsed?.version === 'string' ? parsed : undefined; + } catch { + return undefined; + } + } + + /** + * Persist cache state, best-effort. + * + * Swallowing the error is deliberate. State is bookkeeping, not the served + * artifact: losing it costs one redundant fetch next launch. Letting it + * throw would be strictly worse, because the disk errors that break this + * write are the same ones that break a download - so the throw would + * propagate out of `ensure()` and withdraw a perfectly good cached bundle, + * violating the cache-present rule. + */ + private async _writeState(state: IDocsCacheState): Promise { + const { files, logger, newId, rootPath } = this._options; + const tmp = joinDocsPath(rootPath, `.state-${newId()}.json`); + try { + await files.writeFile(tmp, JSON.stringify(state, undefined, '\t')); + await files.rename(tmp, joinDocsPath(rootPath, DOCS_STATE_FILENAME)); + } catch (error) { + logger.warn(`${LOG_PREFIX} could not persist cache state: ${errorMessage(error)}`); + await this._safeDelete(tmp); + } + } + + /** + * Whether the bundle `state` names is usable. + * + * Note this never re-hashes: `state.sha256` is a diagnostic record of what + * was verified before extraction, not a live checksum. The structural + * checks here are the proportionate ones for Markdown the assistant reads + * as text. + */ + private async _readCached(state: IDocsCacheState | undefined, exactVersion: string): Promise { + // An empty version means `_recordFailure` wrote state with no bundle ever + // installed. `joinDocsPath` drops empty segments, so computing the path + // anyway would validate rootPath itself and warn that a cache which never + // existed is now unusable. + if (!state || !state.version) { + return undefined; + } + const dir = joinDocsPath(this._options.rootPath, state.version); + const validation = await validateExtractedBundle(this._options.files, dir); + if (!validation.ok) { + this._options.logger.warn(`${LOG_PREFIX} cached bundle at ${dir} is unusable (${validation.reason})`); + return undefined; + } + // Derived from the running build, not from state.resolution: after an + // app update a bundle recorded as `exact` no longer is one. + return toLocalDocs(dir, validation.manifest, validation.manifest.version === exactVersion); + } + + /** + * Persist a hard failure so the next session honours the throttle. + * + * `lastAttemptAt` records every attempt for diagnostics; `lastFailureAt` is + * the field the throttle reads. Keeping them separate avoids a bug where a + * successful 304 silently suppresses the next convergence check. + */ + private async _recordFailure( + state: IDocsCacheState | undefined, + request: IDocsBundleRequest, + requestedVersion: string, + resolution: DocsResolution, + reason: string, + ): Promise { + const now = this._options.now(); + await this._writeState({ + schema: DOCS_BUNDLE_SCHEMA, + version: state?.version ?? '', + requestedVersion, + resolution: state?.resolution ?? resolution, + profile: request.profile, + sha256: state?.sha256 ?? '', + etag: state?.etag, + sourceUrl: state?.sourceUrl ?? '', + fetchedAt: state?.fetchedAt ?? 0, + lastAttemptAt: now, + lastFailureAt: now, + lastError: reason, + }); + } + + private async _safeDelete(path: string): Promise { + try { + await this._options.files.delete(path); + } catch { + // Cleanup is best-effort. Anything left behind is a few hundred KB + // of crash debris, bounded by the number of crashes, not by time. + } + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/vs/platform/positronDocs/common/positronDocsIO.ts b/src/vs/platform/positronDocs/common/positronDocsIO.ts new file mode 100644 index 00000000000..8aaac059b15 --- /dev/null +++ b/src/vs/platform/positronDocs/common/positronDocsIO.ts @@ -0,0 +1,92 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * The I/O interfaces the docs cache is built against. Deliberately three narrow + * ones rather than a single wide one, so each test fake stays small and the seam + * can be re-hosted in a node service later without a rewrite. + * + * Paths are plain strings joined with forward slashes. Node's fs accepts + * forward slashes on Windows, so no platform-specific joining is needed here + * and `common` stays free of node imports. + */ + +export interface IDocsHttpResponse { + readonly status: number; + readonly etag?: string; + /** Absent on 304, on any error status, and on HEAD. */ + readonly body?: Uint8Array; +} + +export interface IDocsHttpGetOptions { + /** Send as `If-None-Match`, so an unchanged alias answers 304. */ + readonly etag?: string; + /** Abort and reject once the response exceeds this many bytes. */ + readonly maxBytes?: number; +} + +export interface IDocsHttpClient { + get(url: string, options?: IDocsHttpGetOptions): Promise; + head(url: string): Promise; +} + +export interface IDocsFileStore { + exists(path: string): Promise; + readFile(path: string): Promise; + writeFile(path: string, data: string | Uint8Array): Promise; + /** Recursive; succeeds if the directory already exists. */ + mkdir(path: string): Promise; + rename(from: string, to: string): Promise; + /** Recursive; succeeds if the path does not exist. */ + delete(path: string): Promise; + /** + * Immediate children, names only. Empty array if the path is missing. + * Only meaningful for directories - call `isDirectory` first rather than + * inferring file-ness from an empty result, since an empty directory and a + * file are indistinguishable here (and a node-backed store throws ENOTDIR + * on a file). + */ + readdir(path: string): Promise; + /** True for a directory, false for a file and for a missing path. */ + isDirectory(path: string): Promise; + /** Lowercase hex digest of the file's bytes. */ + sha256(path: string): Promise; +} + +export interface IDocsArchive { + /** Entry paths as recorded in the archive, before any extraction. */ + entryNames(zipPath: string): Promise; + extract(zipPath: string, targetPath: string): Promise; +} + +/** + * Narrow logger so the seam does not depend on ILogService. Nothing here is + * user-actionable, so there is deliberately no error level. + */ +export interface IDocsLogger { + info(message: string): void; + warn(message: string): void; +} + +/** What `positron.docs.getLocalDocs()` resolves to. */ +export interface ILocalDocs { + readonly path: string; + readonly schema: number; + readonly version: string; + readonly profile: string; + readonly docsBaseUrl: string; + readonly isExactMatch: boolean; +} + +/** Join path segments with forward slashes, collapsing duplicates. */ +export function joinDocsPath(...segments: string[]): string { + return segments + .filter(segment => segment.length > 0) + .map((segment, index) => index === 0 ? segment.replace(/\/+$/, '') : segment.replace(/^\/+|\/+$/g, '')) + // Filtered again after stripping: an all-slashes segment strips to empty + // and would otherwise join into a stray trailing or doubled slash. + .filter((segment, index) => index === 0 || segment.length > 0) + .join('/'); +} diff --git a/src/vs/platform/positronDocs/common/positronDocsValidate.ts b/src/vs/platform/positronDocs/common/positronDocsValidate.ts new file mode 100644 index 00000000000..bcd3341bdc1 --- /dev/null +++ b/src/vs/platform/positronDocs/common/positronDocsValidate.ts @@ -0,0 +1,95 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DOCS_INDEX_FILENAME, DOCS_MANIFEST_FILENAME, IDocsBundleManifest, parseManifest } from './positronDocsBundle.js'; +import { IDocsFileStore, joinDocsPath } from './positronDocsIO.js'; + +export type DocsValidationFailure = + | 'missing-manifest' + | 'missing-index' + | 'bad-manifest' + | 'file-count-mismatch'; + +export type DocsValidationResult = + | { readonly ok: true; readonly manifest: IDocsBundleManifest } + | { readonly ok: false; readonly reason: DocsValidationFailure }; + +/** + * Reject archive entries that could write outside the extraction target. + * + * base/node/zip.ts does some of this, but the archive arrives over the network, + * so we assert it ourselves rather than trusting it. Returns the first + * offending entry, or undefined when every entry is safe. + */ +export function guardEntryNames(names: readonly string[]): string | undefined { + for (const name of names) { + if (name.includes('\u0000')) { + return name; + } + // Normalise Windows separators before reasoning about segments. + const normalized = name.replace(/\\/g, '/'); + if (normalized.startsWith('/') || /^[a-zA-Z]:/.test(normalized)) { + return name; + } + let depth = 0; + for (const segment of normalized.split('/')) { + if (segment === '' || segment === '.') { + continue; + } + depth += segment === '..' ? -1 : 1; + if (depth < 0) { + return name; + } + } + } + return undefined; +} + +/** + * Check an extracted bundle before it is swapped into place. + * + * Deliberately structural and cheap: bundle.json parses at a schema we + * understand, llms.txt is present, and the extracted file count matches what + * the manifest declared. A corrupted byte inside a Markdown page degrades one + * assistant answer rather than compromising anything, so byte-level integrity + * is the digest's job at download time, not this function's. + */ +export async function validateExtractedBundle(files: IDocsFileStore, dir: string): Promise { + const manifestPath = joinDocsPath(dir, DOCS_MANIFEST_FILENAME); + if (!await files.exists(manifestPath)) { + return { ok: false, reason: 'missing-manifest' }; + } + if (!await files.exists(joinDocsPath(dir, DOCS_INDEX_FILENAME))) { + return { ok: false, reason: 'missing-index' }; + } + + const manifest = parseManifest(await files.readFile(manifestPath)); + if (!manifest) { + return { ok: false, reason: 'bad-manifest' }; + } + + const actual = await countFiles(files, dir); + if (actual !== manifest.fileCount) { + return { ok: false, reason: 'file-count-mismatch' }; + } + return { ok: true, manifest }; +} + +async function countFiles(files: IDocsFileStore, dir: string): Promise { + let count = 0; + for (const name of await files.readdir(dir)) { + const child = joinDocsPath(dir, name); + // Ask the store what the child is rather than inferring it from an empty + // readdir. An empty directory would otherwise count as one file - and + // real extractors do create them, from explicit directory entries in the + // archive - producing a spurious file-count-mismatch. + if (await files.isDirectory(child)) { + count += await countFiles(files, child); + } else { + count += 1; + } + } + return count; +} diff --git a/src/vs/platform/positronDocs/test/common/fakes.ts b/src/vs/platform/positronDocs/test/common/fakes.ts new file mode 100644 index 00000000000..8896ad4b25e --- /dev/null +++ b/src/vs/platform/positronDocs/test/common/fakes.ts @@ -0,0 +1,232 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IDocsArchive, IDocsFileStore, IDocsHttpClient, IDocsHttpGetOptions, IDocsHttpResponse, IDocsLogger } from '../../common/positronDocsIO.js'; + +/** + * Deterministic stand-in for a real sha256. Only equality matters in these + * tests, and exporting it lets a test compute the digest a checksum file should + * carry without duplicating the algorithm. + */ +export function fakeDigest(contents: string): string { + let hash = 0; + for (let i = 0; i < contents.length; i++) { + hash = (Math.imul(hash, 31) + contents.charCodeAt(i)) | 0; + } + return (hash >>> 0).toString(16).padStart(64, '0'); +} + +/** + * In-memory file store. Directories are implicit: a path is a directory if any + * stored key starts with it plus a slash. + */ +export class FakeFileStore implements IDocsFileStore { + readonly files = new Map(); + /** Directories created explicitly via mkdir; implicit ones live in `files` keys. */ + readonly dirs = new Set(); + /** Set to a path prefix to make every write under it fail, simulating a full disk. */ + failWritesUnder: string | undefined; + /** Digest overrides, keyed by path. Defaults to a hash of the contents. */ + readonly digests = new Map(); + + constructor(initial: Record = {}) { + for (const [path, contents] of Object.entries(initial)) { + this.files.set(path, contents); + } + } + + private isDir(path: string): boolean { + const prefix = `${path}/`; + for (const key of this.files.keys()) { + if (key.startsWith(prefix)) { + return true; + } + } + return this.dirs.has(path); + } + + async exists(path: string): Promise { + return this.files.has(path) || this.isDir(path); + } + + async readFile(path: string): Promise { + const contents = this.files.get(path); + if (contents === undefined) { + throw new Error(`ENOENT: ${path}`); + } + return contents; + } + + async writeFile(path: string, data: string | Uint8Array): Promise { + if (this.failWritesUnder && path.startsWith(this.failWritesUnder)) { + throw new Error(`ENOSPC: no space left on device, write '${path}'`); + } + // Decode rather than record a length: the cache writes downloaded zip + // bytes through this method, and FakeArchive must be able to read them + // back as its fake-zip payload string. + this.files.set(path, typeof data === 'string' ? data : new TextDecoder().decode(data)); + } + + async mkdir(path: string): Promise { + this.dirs.add(path); + } + + async rename(from: string, to: string): Promise { + for (const [key, value] of [...this.files]) { + if (key === from || key.startsWith(`${from}/`)) { + this.files.delete(key); + this.files.set(to + key.slice(from.length), value); + } + } + for (const key of [...this.dirs]) { + if (key === from || key.startsWith(`${from}/`)) { + this.dirs.delete(key); + this.dirs.add(to + key.slice(from.length)); + } + } + } + + async delete(path: string): Promise { + for (const key of [...this.files.keys()]) { + if (key === path || key.startsWith(`${path}/`)) { + this.files.delete(key); + } + } + for (const key of [...this.dirs]) { + if (key === path || key.startsWith(`${path}/`)) { + this.dirs.delete(key); + } + } + } + + async readdir(path: string): Promise { + const prefix = `${path}/`; + const names = new Set(); + for (const key of [...this.files.keys(), ...this.dirs]) { + if (key.startsWith(prefix)) { + names.add(key.slice(prefix.length).split('/')[0]); + } + } + return [...names]; + } + + async isDirectory(path: string): Promise { + // A path present in `files` holds contents, so it is a file. + return this.files.has(path) ? false : this.isDir(path); + } + + async sha256(path: string): Promise { + const override = this.digests.get(path); + if (override !== undefined) { + return override; + } + const contents = this.files.get(path); + if (contents === undefined) { + throw new Error(`ENOENT: ${path}`); + } + return fakeDigest(contents); + } + + /** Every file path currently stored under `dir`, recursively. */ + listUnder(dir: string): string[] { + return [...this.files.keys()].filter(key => key.startsWith(`${dir}/`)).sort(); + } +} + +export interface IFakeHttpRoute { + readonly status: number; + readonly body?: string; + readonly etag?: string; + /** Throw instead of responding, simulating DNS or connection failure. */ + readonly throws?: string; + /** Response size in bytes for the maxBytes check; defaults to body length. */ + readonly byteLength?: number; +} + +export class FakeHttpClient implements IDocsHttpClient { + readonly getCalls: string[] = []; + readonly headCalls: string[] = []; + private readonly routes = new Map(); + + route(url: string, route: IFakeHttpRoute): this { + this.routes.set(url, route); + return this; + } + + async get(url: string, options?: IDocsHttpGetOptions): Promise { + this.getCalls.push(url); + const route = this.routes.get(url) ?? { status: 404 }; + if (route.throws) { + throw new Error(route.throws); + } + const size = route.byteLength ?? route.body?.length ?? 0; + if (options?.maxBytes !== undefined && size > options.maxBytes) { + throw new Error(`docs bundle exceeds ${options.maxBytes} bytes`); + } + if (options?.etag !== undefined && route.etag !== undefined && options.etag === route.etag) { + return { status: 304, etag: route.etag }; + } + if (route.status !== 200) { + return { status: route.status }; + } + return { status: 200, etag: route.etag, body: new TextEncoder().encode(route.body ?? '') }; + } + + async head(url: string): Promise { + this.headCalls.push(url); + const route = this.routes.get(url) ?? { status: 404 }; + if (route.throws) { + throw new Error(route.throws); + } + return { status: route.status, etag: route.etag }; + } +} + +/** + * Fake archive. A "zip" is the string the file store holds at its path, of the + * form `zip:`. + * + * JSON rather than a flat `name=contents;...` encoding: entry contents include + * bundle.json and Markdown, so any separator character can legitimately appear + * inside a value. A flat format would silently mis-split instead of failing. + */ +export class FakeArchive implements IDocsArchive { + constructor(private readonly files: FakeFileStore) { } + + private parse(zipPath: string): Array<[string, string]> { + const raw = this.files.files.get(zipPath); + if (raw === undefined || !raw.startsWith('zip:')) { + throw new Error(`end of central directory record signature not found: ${zipPath}`); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw.slice(4)); + } catch { + throw new Error(`end of central directory record signature not found: ${zipPath}`); + } + return Object.entries(parsed as Record); + } + + async entryNames(zipPath: string): Promise { + return this.parse(zipPath).map(([name]) => name); + } + + async extract(zipPath: string, targetPath: string): Promise { + for (const [name, contents] of this.parse(zipPath)) { + await this.files.writeFile(`${targetPath}/${name}`, contents); + } + } +} + +/** Build the fake-zip payload string for a set of entries. */ +export function fakeZip(entries: Record): string { + return `zip:${JSON.stringify(entries)}`; +} + +export function recordingLogger(): IDocsLogger & { readonly infos: string[]; readonly warns: string[] } { + const infos: string[] = []; + const warns: string[] = []; + return { infos, warns, info: (m: string) => { infos.push(m); }, warn: (m: string) => { warns.push(m); } }; +} diff --git a/src/vs/platform/positronDocs/test/common/positronDocsBundle.vitest.ts b/src/vs/platform/positronDocs/test/common/positronDocsBundle.vitest.ts new file mode 100644 index 00000000000..77576b890c8 --- /dev/null +++ b/src/vs/platform/positronDocs/test/common/positronDocsBundle.vitest.ts @@ -0,0 +1,117 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ +/// + +import { IDocsBundleRequest, parseManifest, parseDigestFile, resolveBundleRequest } from '../../common/positronDocsBundle.js'; + +const BASE = 'https://cdn.posit.co/positron/releases/docs'; + +/** Same shape as the helper in positronDocsCache.vitest.ts, deliberately. */ +function request(overrides: Partial = {}): IDocsBundleRequest { + return { + quality: 'releases', + positronVersion: '2026.05.0', + positronBuildNumber: 179, + profile: 'positron', + baseUrl: BASE, + ...overrides, + }; +} + +describe('resolveBundleRequest', () => { + // The three quality values are verified against build/utils.ts, not assumed. + // A future channel rename must fail here rather than silently change behaviour. + it.each<[string | undefined, boolean]>([ + ['releases', true], + ['dailies', false], + [undefined, false], + ])('quality %s => wantsExact %s', (quality, wantsExact) => { + expect(resolveBundleRequest(request({ quality })).wantsExact).toBe(wantsExact); + }); + + it('builds exact and latest URLs plus checksum files for the positron profile', () => { + const { exact, latest } = resolveBundleRequest(request()); + expect({ exact, latest }).toMatchInlineSnapshot(` + { + "exact": { + "form": "exact", + "sha256Url": "https://cdn.posit.co/positron/releases/docs/positron-llms-2026.05.0-179.zip.sha256sum", + "version": "2026.05.0-179", + "zipUrl": "https://cdn.posit.co/positron/releases/docs/positron-llms-2026.05.0-179.zip", + }, + "latest": { + "form": "latest", + "sha256Url": "https://cdn.posit.co/positron/releases/docs/positron-llms-latest.zip.sha256sum", + "version": "latest", + "zipUrl": "https://cdn.posit.co/positron/releases/docs/positron-llms-latest.zip", + }, + } + `); + }); + + it('uses the workbench basename for the workbench profile', () => { + expect(resolveBundleRequest(request({ profile: 'workbench' })).exact.zipUrl) + .toBe(`${BASE}/positron-workbench-llms-2026.05.0-179.zip`); + }); + + it('omits the -0 suffix for dev builds', () => { + expect(resolveBundleRequest(request({ positronBuildNumber: 0 })).exact.version).toBe('2026.05.0'); + }); + + it('tolerates a trailing slash on the base URL', () => { + expect(resolveBundleRequest(request({ baseUrl: `${BASE}/` })).latest.zipUrl) + .toBe(`${BASE}/positron-llms-latest.zip`); + }); +}); + +describe('parseManifest', () => { + const valid = JSON.stringify({ + schema: 1, profile: 'positron', version: '2026.05.0-179', + generated: '2026-07-24T18:02:11Z', docsBaseUrl: 'https://positron.posit.co/', fileCount: 90, + }); + + it('accepts a well-formed schema 1 manifest', () => { + expect(parseManifest(valid)).toMatchInlineSnapshot(` + { + "docsBaseUrl": "https://positron.posit.co/", + "fileCount": 90, + "generated": "2026-07-24T18:02:11Z", + "profile": "positron", + "schema": 1, + "version": "2026.05.0-179", + } + `); + }); + + it.each([ + ['schema 2', JSON.stringify({ ...JSON.parse(valid), schema: 2 })], + ['malformed JSON', '{ not json'], + ['missing version', JSON.stringify({ schema: 1, profile: 'positron', fileCount: 90, docsBaseUrl: 'x', generated: 'y' })], + ['non-numeric fileCount', JSON.stringify({ ...JSON.parse(valid), fileCount: 'ninety' })], + ])('rejects %s', (_label, raw) => { + expect(parseManifest(raw)).toBeUndefined(); + }); +}); + +describe('parseDigestFile', () => { + const digest = 'a'.repeat(64); + + it.each([ + ['shasum format', `${digest} positron-llms-latest.zip\n`], + ['bare hex', `${digest}\n`], + ['uppercase hex', `${digest.toUpperCase()} x.zip`], + ])('accepts %s', (_label, raw) => { + expect(parseDigestFile(raw)).toBe(digest); + }); + + it.each([ + ['empty', ''], + ['too short', 'abc123 x.zip'], + ['non-hex', `${'z'.repeat(64)} x.zip`], + ['an HTML error page', '404'], + ])('rejects %s', (_label, raw) => { + expect(parseDigestFile(raw)).toBeUndefined(); + }); +}); diff --git a/src/vs/platform/positronDocs/test/common/positronDocsCache.vitest.ts b/src/vs/platform/positronDocs/test/common/positronDocsCache.vitest.ts new file mode 100644 index 00000000000..ca630954850 --- /dev/null +++ b/src/vs/platform/positronDocs/test/common/positronDocsCache.vitest.ts @@ -0,0 +1,636 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ +/// + +import { DOCS_MAX_DOWNLOAD_BYTES, IDocsBundleRequest } from '../../common/positronDocsBundle.js'; +import { PositronDocsCache } from '../../common/positronDocsCache.js'; +import { fakeDigest, fakeZip, FakeArchive, FakeFileStore, FakeHttpClient, recordingLogger } from './fakes.js'; + +const ROOT = '/userdata/User/positron-llm-docs'; +const BASE = 'https://cdn.posit.co/positron/releases/docs'; +const EXACT_ZIP = `${BASE}/positron-llms-2026.05.0-179.zip`; +const LATEST_ZIP = `${BASE}/positron-llms-latest.zip`; +const STATE_PATH = `${ROOT}/state.json`; + +/** A fake-zip payload whose manifest declares the three files it contains. */ +function payload(version: string): string { + return fakeZip({ + 'bundle.json': JSON.stringify({ + schema: 1, profile: 'positron', version, + generated: '2026-07-24T18:02:11Z', + docsBaseUrl: 'https://positron.posit.co/', fileCount: 3, + }), + 'llms.txt': '# Positron\n\n- [Welcome](welcome.llms.md)\n', + 'welcome.llms.md': '# Welcome\n', + }); +} + +function request(overrides: Partial = {}): IDocsBundleRequest { + return { + quality: 'releases', + positronVersion: '2026.05.0', + positronBuildNumber: 179, + profile: 'positron', + baseUrl: BASE, + ...overrides, + }; +} + +function setup() { + let clock = 1_000_000; + let ids = 0; + const files = new FakeFileStore(); + const http = new FakeHttpClient(); + const archive = new FakeArchive(files); + const logger = recordingLogger(); + // A fresh cache over the same fakes stands in for a new session: it + // re-reads state.json from the shared file store, exactly as a relaunch + // would. Tests that model "the next launch" must use this rather than + // calling ensure() twice on one instance. + const makeCache = () => new PositronDocsCache({ + rootPath: ROOT, http, files, archive, logger, + now: () => clock, + newId: () => `id${++ids}`, + }); + const cache = makeCache(); + return { + cache, makeCache, files, http, archive, logger, + advance: (ms: number) => { clock += ms; }, + /** The persisted cache state, parsed. */ + readState: async () => JSON.parse(await files.readFile(STATE_PATH)), + /** Serve `zipUrl` with a matching, correctly-formatted checksum file. */ + publish: (zipUrl: string, body: string, etag?: string) => { + http.route(zipUrl, { status: 200, body, etag }); + http.route(`${zipUrl}.sha256sum`, { status: 200, body: `${fakeDigest(body)} bundle.zip\n` }); + }, + }; +} + +describe('PositronDocsCache: cold cache install', () => { + it('downloads, verifies, extracts, and swaps in a release build bundle', async () => { + const ctx = setup(); + ctx.publish(EXACT_ZIP, payload('2026.05.0-179')); + + const docs = await ctx.cache.ensure(request()); + + expect(docs).toMatchInlineSnapshot(` + { + "docsBaseUrl": "https://positron.posit.co/", + "isExactMatch": true, + "path": "/userdata/User/positron-llm-docs/2026.05.0-179", + "profile": "positron", + "schema": 1, + "version": "2026.05.0-179", + } + `); + }); + + it('records state naming the installed version', async () => { + const ctx = setup(); + ctx.publish(EXACT_ZIP, payload('2026.05.0-179')); + await ctx.cache.ensure(request()); + + const state = await ctx.readState(); + expect({ + version: state.version, requestedVersion: state.requestedVersion, + resolution: state.resolution, profile: state.profile, sourceUrl: state.sourceUrl, + }).toMatchInlineSnapshot(` + { + "profile": "positron", + "requestedVersion": "2026.05.0-179", + "resolution": "exact", + "sourceUrl": "https://cdn.posit.co/positron/releases/docs/positron-llms-2026.05.0-179.zip", + "version": "2026.05.0-179", + } + `); + }); + + it('leaves no temp or staging entries behind', async () => { + const ctx = setup(); + ctx.publish(EXACT_ZIP, payload('2026.05.0-179')); + await ctx.cache.ensure(request()); + + // readdir rather than listUnder: an empty leftover staging directory has no + // file keys, so listUnder cannot see the leak this test is named for. + expect((await ctx.files.readdir(ROOT)).filter(name => name.startsWith('.'))).toEqual([]); + }); + + it('fetches the latest alias for a dailies build', async () => { + const ctx = setup(); + ctx.publish(LATEST_ZIP, payload('2026.05.0-179')); + + expect(await ctx.cache.ensure(request({ quality: 'dailies' }))).toBeDefined(); + expect(ctx.http.getCalls).toContain(LATEST_ZIP); + expect(ctx.http.getCalls).not.toContain(EXACT_ZIP); + }); +}); + +describe('PositronDocsCache: warm exact cache', () => { + it('serves the cache without touching the network', async () => { + const ctx = setup(); + ctx.publish(EXACT_ZIP, payload('2026.05.0-179')); + await ctx.cache.ensure(request()); + const getsAfterInstall = ctx.http.getCalls.length; + const headsAfterInstall = ctx.http.headCalls.length; + + const docs = await ctx.makeCache().ensure(request()); + + expect(docs?.isExactMatch).toBe(true); + // Release builds are network-free once exactly matched. This is the + // whole point of version-stamping the cache directory. Counted as a + // delta rather than an absolute: the install itself HEADs the exact URL, + // so what matters is that the next launch adds nothing. + expect(ctx.http.getCalls.length).toBe(getsAfterInstall); + expect(ctx.http.headCalls.length).toBe(headsAfterInstall); + }); +}); + +describe('PositronDocsCache: download rejections on a cold cache', () => { + // Each of these must leave no version directory behind and return + // undefined, so the assistant falls back to the web exactly as it does + // today. Task 6 asserts the same failures against a warm cache. + // `expectedLog` is what makes each case falsifiable: every rejection produces + // the same three observable outcomes, so without a distinct reason a test + // would still pass if the code refused the bundle for the wrong reason. + // Matched across both levels on purpose - which level each outcome kind logs + // at is asserted separately below. + async function expectRejected(configure: (ctx: ReturnType) => void, expectedLog: string) { + const ctx = setup(); + configure(ctx); + const docs = await ctx.cache.ensure(request()); + expect(docs).toBeUndefined(); + expect(await ctx.files.exists(`${ROOT}/2026.05.0-179`)).toBe(false); + expect(ctx.files.listUnder(ROOT).filter(p => p.includes('/.tmp-') || p.includes('/.staging-'))).toEqual([]); + expect([...ctx.logger.warns, ...ctx.logger.infos].join('\n')).toContain(expectedLog); + return ctx; + } + + it('rejects when the checksum file 404s', async () => { + await expectRejected(c => { + c.http.route(EXACT_ZIP, { status: 200, body: payload('2026.05.0-179') }); + c.http.route(`${EXACT_ZIP}.sha256sum`, { status: 404 }); + }, 'checksum file unavailable (HTTP 404)'); + }); + + it('rejects when the checksum file is unparseable', async () => { + await expectRejected(c => { + c.http.route(EXACT_ZIP, { status: 200, body: payload('2026.05.0-179') }); + c.http.route(`${EXACT_ZIP}.sha256sum`, { status: 200, body: '404' }); + }, 'checksum file does not hold a sha256 digest'); + }); + + it('rejects when the digest does not match the zip', async () => { + await expectRejected(c => { + c.http.route(EXACT_ZIP, { status: 200, body: payload('2026.05.0-179') }); + c.http.route(`${EXACT_ZIP}.sha256sum`, { status: 200, body: `${'b'.repeat(64)} bundle.zip` }); + }, 'digest mismatch'); + }); + + it('rejects a corrupt archive', async () => { + await expectRejected(c => { + c.publish(EXACT_ZIP, 'not-a-zip-at-all'); + }, 'corrupt archive'); + }); + + it('rejects an archive entry that escapes the target', async () => { + await expectRejected(c => { + c.publish(EXACT_ZIP, fakeZip({ 'llms.txt': 'x', '../../evil.sh': 'rm -rf /' })); + }, 'archive entry escapes the target: ../../evil.sh'); + }); + + it('rejects a bundle whose schema is not 1', async () => { + await expectRejected(c => { + c.publish(EXACT_ZIP, fakeZip({ + 'bundle.json': JSON.stringify({ schema: 2, profile: 'positron', version: '2026.05.0-179', generated: 'g', docsBaseUrl: 'd', fileCount: 2 }), + 'llms.txt': '# Positron\n', + })); + }, 'extracted bundle invalid (bad-manifest)'); + }); + + it('rejects a bundle whose fileCount does not match', async () => { + await expectRejected(c => { + c.publish(EXACT_ZIP, fakeZip({ + 'bundle.json': JSON.stringify({ schema: 1, profile: 'positron', version: '2026.05.0-179', generated: 'g', docsBaseUrl: 'd', fileCount: 99 }), + 'llms.txt': '# Positron\n', + })); + }, 'extracted bundle invalid (file-count-mismatch)'); + }); + + it('aborts a download that exceeds the size cap', async () => { + await expectRejected(c => { + c.http.route(EXACT_ZIP, { status: 200, body: payload('2026.05.0-179'), byteLength: DOCS_MAX_DOWNLOAD_BYTES + 1 }); + c.http.route(`${EXACT_ZIP}.sha256sum`, { status: 200, body: `${'c'.repeat(64)} x` }); + }, `exceeds ${DOCS_MAX_DOWNLOAD_BYTES} bytes`); + }); + + it('returns undefined on a network failure', async () => { + await expectRejected(c => { + c.http.route(EXACT_ZIP, { status: 0, throws: 'getaddrinfo ENOTFOUND cdn.posit.co' }); + }, 'getaddrinfo ENOTFOUND cdn.posit.co'); + }); + + it('returns undefined on a 5xx', async () => { + await expectRejected(c => { + c.http.route(EXACT_ZIP, { status: 503 }); + }, 'unexpected HTTP 503 from HEAD'); + }); + + it('returns undefined on a disk write error', async () => { + await expectRejected(c => { + c.publish(EXACT_ZIP, payload('2026.05.0-179')); + c.files.failWritesUnder = ROOT; + }, 'ENOSPC'); + }); + + it('logs a fetch failure at info, and a refused payload at warn', async () => { + // A docs download failing is not worth interrupting anyone over, so an + // unreachable CDN stays at info. A payload that arrived and was refused + // is different: something is wrong with what was published, and that + // earns a warn. + const failed = await expectRejected(c => { + c.http.route(EXACT_ZIP, { status: 404 }); + c.http.route(LATEST_ZIP, { status: 0, throws: 'getaddrinfo ENOTFOUND cdn.posit.co' }); + }, 'fetch failed for'); + expect(failed.logger.warns).toEqual([]); + + const refused = await expectRejected(c => { + c.publish(EXACT_ZIP, 'not-a-zip-at-all'); + }, 'corrupt archive'); + expect(refused.logger.warns.join('\n')).toContain('rejected bundle from'); + }); +}); + +describe('PositronDocsCache: damaged cache state', () => { + it('reinstalls when state names a version directory that is gone', async () => { + const ctx = setup(); + ctx.publish(EXACT_ZIP, payload('2026.05.0-179')); + await ctx.cache.ensure(request()); + + // Someone cleared part of the cache directory by hand, leaving state.json + // pointing at nothing. The bundle must come back, not be served from a + // path that no longer exists. + await ctx.files.delete(`${ROOT}/2026.05.0-179`); + + const docs = await ctx.makeCache().ensure(request()); + + expect(docs?.version).toBe('2026.05.0-179'); + expect(await ctx.files.exists(`${ROOT}/2026.05.0-179/llms.txt`)).toBe(true); + expect(ctx.logger.warns.join('\n')).toContain('is unusable (missing-manifest)'); + }); + + it('treats an unparseable state.json as a cold cache', async () => { + const ctx = setup(); + ctx.publish(EXACT_ZIP, payload('2026.05.0-179')); + await ctx.files.writeFile(STATE_PATH, '{ not json'); + + const docs = await ctx.cache.ensure(request()); + + expect(docs?.version).toBe('2026.05.0-179'); + expect((await ctx.readState()).resolution).toBe('exact'); + }); +}); + +describe('PositronDocsCache: convergence', () => { + it('serves the fallback bundle first, then converges to exact', async () => { + const ctx = setup(); + // Exact is not published yet; latest holds an older release's docs. + ctx.http.route(EXACT_ZIP, { status: 404 }); + ctx.http.route(`${EXACT_ZIP}.sha256sum`, { status: 404 }); + ctx.publish(LATEST_ZIP, payload('2026.04.0-100'), 'etag-april'); + + // Intermediate state matters: a test that checked only the end state + // would pass even if the fallback never worked. + const first = await ctx.cache.ensure(request()); + expect(first?.version).toBe('2026.04.0-100'); + expect(first?.isExactMatch).toBe(false); + expect((await ctx.readState()).resolution).toBe('fallback'); + + // The release's docs publish. The next launch converges. + ctx.publish(EXACT_ZIP, payload('2026.05.0-179')); + + const second = await ctx.makeCache().ensure(request()); + expect(second?.version).toBe('2026.05.0-179'); + expect(second?.isExactMatch).toBe(true); + expect((await ctx.readState()).resolution).toBe('exact'); + // Converging also cleans up: the fallback bundle is superseded. + expect(await ctx.files.exists(`${ROOT}/2026.04.0-100`)).toBe(false); + }); + + it('keeps the cached bundle when latest answers 304', async () => { + const ctx = setup(); + ctx.http.route(EXACT_ZIP, { status: 404 }); + ctx.publish(LATEST_ZIP, payload('2026.04.0-100'), 'etag-april'); + await ctx.cache.ensure(request()); + const before = ctx.files.listUnder(ROOT); + + ctx.advance(5 * 60 * 1000); + const second = await ctx.makeCache().ensure(request()); + + expect(second?.version).toBe('2026.04.0-100'); + expect(ctx.files.listUnder(ROOT)).toEqual(before); + expect(ctx.logger.infos.join('\n')).toContain('unchanged (304)'); + // listUnder only compares paths, so it cannot see whether _touchState + // ran. Assert the content moved too, or a regression that stopped + // touching state would pass unnoticed. + const state = await ctx.readState(); + expect(state.resolution).toBe('fallback'); + expect(state.lastAttemptAt).toBe(1_000_000 + 5 * 60 * 1000); + }); + + it('replaces the cached bundle when latest moves', async () => { + const ctx = setup(); + ctx.http.route(EXACT_ZIP, { status: 404 }); + ctx.publish(LATEST_ZIP, payload('2026.04.0-100'), 'etag-april'); + await ctx.cache.ensure(request()); + + ctx.publish(LATEST_ZIP, payload('2026.05.0-179'), 'etag-may'); + const second = await ctx.makeCache().ensure(request()); + + expect(second?.version).toBe('2026.05.0-179'); + }); + + it('re-enters fallback when the app updates past the cached bundle', async () => { + const ctx = setup(); + ctx.publish(EXACT_ZIP, payload('2026.05.0-179')); + const first = await ctx.cache.ensure(request()); + expect(first?.isExactMatch).toBe(true); + + // The user updates to a release whose docs have not published yet. + const updated = request({ positronVersion: '2026.06.0', positronBuildNumber: 42 }); + ctx.http.route(`${BASE}/positron-llms-2026.06.0-42.zip`, { status: 404 }); + ctx.http.route(LATEST_ZIP, { status: 404 }); + + const second = await ctx.makeCache().ensure(updated); + + // Local docs never silently regress to web-only because of an update. + expect(second?.version).toBe('2026.05.0-179'); + expect(second?.isExactMatch).toBe(false); + }); + + it('never HEADs the exact URL on a dailies build', async () => { + const ctx = setup(); + ctx.publish(LATEST_ZIP, payload('2026.05.0-179')); + await ctx.cache.ensure(request({ quality: 'dailies' })); + expect(ctx.http.headCalls).toEqual([]); + }); + + it('HEADs the exact URL again on the very next launch while in fallback', async () => { + const ctx = setup(); + ctx.http.route(EXACT_ZIP, { status: 404 }); + ctx.publish(LATEST_ZIP, payload('2026.04.0-100'), 'etag-april'); + await ctx.cache.ensure(request()); + await ctx.makeCache().ensure(request()); + + // The 404 convergence check is deliberately never throttled. + expect(ctx.http.headCalls.filter(url => url === EXACT_ZIP)).toHaveLength(2); + }); +}); + +describe('PositronDocsCache: cache-present rule', () => { + /** Install a good bundle, then break the next launch's fetch. */ + async function withWarmCache(breakIt: (ctx: ReturnType) => void) { + const ctx = setup(); + ctx.publish(LATEST_ZIP, payload('2026.04.0-100'), 'etag-april'); + const first = await ctx.cache.ensure(request({ quality: 'dailies' })); + expect(first?.version).toBe('2026.04.0-100'); + + breakIt(ctx); + return { ctx, second: await ctx.makeCache().ensure(request({ quality: 'dailies' })) }; + } + + // This is the finding that broke the first draft of the design, so every + // failure kind gets explicit coverage rather than one representative case. + it.each([ + ['network failure', (c: ReturnType) => c.http.route(LATEST_ZIP, { status: 0, throws: 'ENOTFOUND' })], + ['5xx', (c: ReturnType) => c.http.route(LATEST_ZIP, { status: 503 })], + ['404 on latest', (c: ReturnType) => c.http.route(LATEST_ZIP, { status: 404 })], + ['corrupt zip', (c: ReturnType) => c.publish(LATEST_ZIP, 'not-a-zip')], + ['schema 2', (c: ReturnType) => c.publish(LATEST_ZIP, fakeZip({ + 'bundle.json': JSON.stringify({ schema: 2, profile: 'positron', version: 'v', generated: 'g', docsBaseUrl: 'd', fileCount: 2 }), + 'llms.txt': 'x', + }))], + ['digest mismatch', (c: ReturnType) => { + c.http.route(LATEST_ZIP, { status: 200, body: payload('2026.05.0-179') }); + c.http.route(`${LATEST_ZIP}.sha256sum`, { status: 200, body: `${'d'.repeat(64)} x` }); + }], + ['missing checksum file', (c: ReturnType) => { + c.http.route(LATEST_ZIP, { status: 200, body: payload('2026.05.0-179') }); + c.http.route(`${LATEST_ZIP}.sha256sum`, { status: 404 }); + }], + ['disk error', (c: ReturnType) => { + c.publish(LATEST_ZIP, payload('2026.05.0-179')); + c.files.failWritesUnder = ROOT; + }], + ])('still serves the warm cache after %s', async (_label, breakIt) => { + const { ctx, second } = await withWarmCache(breakIt); + + expect(second?.version).toBe('2026.04.0-100'); + // The previously installed directory survives untouched. + expect(await ctx.files.exists(`${ROOT}/2026.04.0-100/llms.txt`)).toBe(true); + }); +}); + +describe('PositronDocsCache: logging', () => { + // Support reads these logs to work out what a build reached for. Naming the + // URL and the decision before the request is what makes a download that + // hangs or dies mid-flight diagnosable at all. + it('names the target and the resolution before an exact request', async () => { + const ctx = setup(); + ctx.publish(EXACT_ZIP, payload('2026.05.0-179')); + await ctx.cache.ensure(request()); + + expect(ctx.logger.infos).toContain(`[llm-docs] fetching ${EXACT_ZIP} (exact)`); + }); + + it('names the target and the resolution when falling back to latest', async () => { + const ctx = setup(); + ctx.http.route(EXACT_ZIP, { status: 404 }); + ctx.publish(LATEST_ZIP, payload('2026.04.0-100')); + await ctx.cache.ensure(request()); + + expect(ctx.logger.infos).toContain(`[llm-docs] fetching ${LATEST_ZIP} (fallback)`); + }); + + it('names the target and the resolution for a dailies build', async () => { + const ctx = setup(); + ctx.publish(LATEST_ZIP, payload('2026.05.0-179')); + await ctx.cache.ensure(request({ quality: 'dailies' })); + + expect(ctx.logger.infos).toContain(`[llm-docs] fetching ${LATEST_ZIP} (latest-by-policy)`); + }); +}); + +describe('PositronDocsCache: a cache installed mid-attempt', () => { + // Two windows opening at once on a cold cache: one installs, the other's + // download fails. The failing window memoizes its result for the session, so + // it has to notice the bundle that landed while it was in flight or it stays + // on web docs until the next relaunch. + it('serves a bundle another window installed while this attempt was in flight', async () => { + const ctx = setup(); + ctx.publish(LATEST_ZIP, payload('2026.05.0-179')); + + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const secondWindow = new PositronDocsCache({ + rootPath: ROOT, files: ctx.files, archive: ctx.archive, logger: ctx.logger, + now: () => 2_000_000, newId: () => 'second', + http: { + // Hangs until released, then fails: this window reads an empty cache + // on the way in and never installs anything itself. + get: async () => { await held; throw new Error('ENOTFOUND'); }, + head: url => ctx.http.head(url), + }, + }); + + const pending = secondWindow.ensure(request({ quality: 'dailies' })); + expect((await ctx.cache.ensure(request({ quality: 'dailies' })))?.version).toBe('2026.05.0-179'); + + release(); + expect((await pending)?.version).toBe('2026.05.0-179'); + + // The failing window records its failure without erasing the version the + // other window installed. Losing that would orphan the bundle on disk for + // every later session, not just this one. + const state = await ctx.readState(); + expect({ version: state.version, lastError: state.lastError }).toEqual({ + version: '2026.05.0-179', + lastError: 'ENOTFOUND', + }); + }); +}); + +describe('PositronDocsCache: single flight', () => { + it('joins two concurrent calls into one download', async () => { + const ctx = setup(); + ctx.publish(EXACT_ZIP, payload('2026.05.0-179')); + + const [a, b] = await Promise.all([ctx.cache.ensure(request()), ctx.cache.ensure(request())]); + + expect(a?.version).toBe('2026.05.0-179'); + expect(b?.version).toBe('2026.05.0-179'); + expect(ctx.http.getCalls.filter(url => url === EXACT_ZIP)).toHaveLength(1); + }); + + it('does not re-attempt within a session, but invalidate() permits one more', async () => { + const ctx = setup(); + ctx.http.route(EXACT_ZIP, { status: 404 }); + ctx.http.route(LATEST_ZIP, { status: 404 }); + await ctx.cache.ensure(request()); + const afterFirst = ctx.http.headCalls.length; + + await ctx.cache.ensure(request()); + expect(ctx.http.headCalls.length).toBe(afterFirst); + + // The one in-session re-attempt the spec allows: an ai.enabled flip. + ctx.cache.invalidate(); + await ctx.cache.ensure(request()); + expect(ctx.http.headCalls.length).toBeGreaterThan(afterFirst); + }); + + it('honours invalidate() called while a fetch is in flight', async () => { + const ctx = setup(); + ctx.http.route(EXACT_ZIP, { status: 404 }); + ctx.http.route(LATEST_ZIP, { status: 404 }); + + // An ai.enabled flip can land mid-download. The completing attempt must + // not re-arm the session gate and swallow the retry. + const inFlight = ctx.cache.ensure(request()); + ctx.cache.invalidate(); + await inFlight; + const afterFirst = ctx.http.headCalls.length; + + await ctx.cache.ensure(request()); + + expect(ctx.http.headCalls.length).toBeGreaterThan(afterFirst); + }); +}); + +describe('PositronDocsCache: hard-failure throttling', () => { + it('records lastFailureAt and skips the next session inside the window', async () => { + const ctx = setup(); + ctx.http.route(EXACT_ZIP, { status: 503 }); + ctx.http.route(LATEST_ZIP, { status: 503 }); + await ctx.cache.ensure(request()); + expect((await ctx.readState()).lastFailureAt).toBeDefined(); + + ctx.advance(59 * 60 * 1000); + const callsBefore = ctx.http.getCalls.length; + await ctx.makeCache().ensure(request()); + + expect(ctx.http.getCalls.length).toBe(callsBefore); + }); + + it('retries once the throttle window has passed', async () => { + const ctx = setup(); + ctx.http.route(EXACT_ZIP, { status: 503 }); + ctx.http.route(LATEST_ZIP, { status: 503 }); + await ctx.cache.ensure(request()); + const callsBefore = ctx.http.getCalls.length; + + ctx.advance(61 * 60 * 1000); + await ctx.makeCache().ensure(request()); + + expect(ctx.http.getCalls.length).toBeGreaterThan(callsBefore); + }); + + it('does not throttle a 404, so convergence keeps running', async () => { + const ctx = setup(); + ctx.http.route(EXACT_ZIP, { status: 404 }); + ctx.publish(LATEST_ZIP, payload('2026.04.0-100'), 'etag-april'); + await ctx.cache.ensure(request()); + + const state = await ctx.readState(); + expect(state.lastFailureAt).toBeUndefined(); + + ctx.advance(60 * 1000); + await ctx.makeCache().ensure(request()); + expect(ctx.http.headCalls.filter(url => url === EXACT_ZIP)).toHaveLength(2); + }); + + it('still serves the cache when persisting the failure marker fails', async () => { + // A full disk breaks both the download and the throttle bookkeeping. + // The bookkeeping is best-effort: it must never turn a served bundle + // into web-only, which is what an unhandled write error would do. + const ctx = setup(); + ctx.publish(LATEST_ZIP, payload('2026.04.0-100'), 'etag-april'); + expect(await ctx.cache.ensure(request({ quality: 'dailies' }))).toBeDefined(); + + ctx.publish(LATEST_ZIP, payload('2026.05.0-179'), 'etag-may'); + ctx.files.failWritesUnder = ROOT; + + const second = await ctx.makeCache().ensure(request({ quality: 'dailies' })); + expect(second?.version).toBe('2026.04.0-100'); + }); +}); + +describe('PositronDocsCache: superseded version cleanup', () => { + it('deletes the superseded version directory on install', async () => { + const ctx = setup(); + ctx.http.route(EXACT_ZIP, { status: 404 }); + ctx.publish(LATEST_ZIP, payload('2026.04.0-100'), 'etag-april'); + await ctx.cache.ensure(request()); + + ctx.publish(LATEST_ZIP, payload('2026.05.0-179'), 'etag-may'); + await ctx.makeCache().ensure(request()); + + expect(await ctx.files.exists(`${ROOT}/2026.05.0-179/llms.txt`)).toBe(true); + expect(await ctx.files.exists(`${ROOT}/2026.04.0-100`)).toBe(false); + }); + + it('never touches entries it did not supersede, including another window in-flight work', async () => { + const ctx = setup(); + ctx.publish(EXACT_ZIP, payload('2026.05.0-179')); + + // Two windows share this directory. Cleanup deletes only the version + // the previous state named - there is no sweep - so a concurrent + // window's temp file survives an install here unconditionally. + await ctx.files.writeFile(`${ROOT}/.tmp-otherwindow.zip`, 'in flight'); + + await ctx.cache.ensure(request()); + + expect(await ctx.files.exists(`${ROOT}/.tmp-otherwindow.zip`)).toBe(true); + expect(await ctx.files.exists(`${ROOT}/2026.05.0-179/llms.txt`)).toBe(true); + }); +}); diff --git a/src/vs/platform/positronDocs/test/common/positronDocsIO.vitest.ts b/src/vs/platform/positronDocs/test/common/positronDocsIO.vitest.ts new file mode 100644 index 00000000000..4c2badc2c92 --- /dev/null +++ b/src/vs/platform/positronDocs/test/common/positronDocsIO.vitest.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ +/// + +import { joinDocsPath } from '../../common/positronDocsIO.js'; + +describe('joinDocsPath', () => { + it.each([ + ['joins plain segments', ['/c', '2026.05.0-179', 'llms.txt'], '/c/2026.05.0-179/llms.txt'], + ['preserves a leading slash', ['/c', 'x'], '/c/x'], + ['drops a trailing slash on the root', ['/c/', 'x'], '/c/x'], + ['collapses slashes between segments', ['/c/', '/x/', '/y'], '/c/x/y'], + ['skips empty segments', ['/c', '', 'x'], '/c/x'], + // An all-slashes segment strips to empty, which used to join into a + // stray trailing slash and produce a path that reads as a directory. + ['skips an all-slashes segment', ['/c', '///'], '/c'], + ['keeps a single segment unchanged', ['/c'], '/c'], + ])('%s', (_label, segments, expected) => { + expect(joinDocsPath(...segments)).toBe(expected); + }); +}); diff --git a/src/vs/platform/positronDocs/test/common/positronDocsValidate.vitest.ts b/src/vs/platform/positronDocs/test/common/positronDocsValidate.vitest.ts new file mode 100644 index 00000000000..efde3e2948f --- /dev/null +++ b/src/vs/platform/positronDocs/test/common/positronDocsValidate.vitest.ts @@ -0,0 +1,88 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ +/// + +import { guardEntryNames, validateExtractedBundle } from '../../common/positronDocsValidate.js'; +import { FakeFileStore } from './fakes.js'; + +describe('guardEntryNames', () => { + it('accepts ordinary nested entries', () => { + expect(guardEntryNames(['llms.txt', 'bundle.json', 'release-notes/release-2026-05.llms.md'])).toBeUndefined(); + }); + + // The archive arrives over the network, so we assert these ourselves rather + // than trusting base/node/zip.ts to have done it. + it.each([ + ['an absolute posix path', '/etc/passwd'], + ['a windows drive path', 'C:\\Windows\\system32'], + ['a parent traversal', '../../outside.md'], + ['a nested parent traversal', 'docs/../../outside.md'], + ['a null byte', 'llms\u0000.txt'], + ['a UNC path', '\\\\server\\share\\x'], + ])('rejects %s', (_label, entry) => { + expect(guardEntryNames(['llms.txt', entry])).toContain(entry); + }); +}); + +describe('validateExtractedBundle', () => { + const manifest = JSON.stringify({ + schema: 1, profile: 'positron', version: '2026.05.0-179', + generated: '2026-07-24T18:02:11Z', docsBaseUrl: 'https://positron.posit.co/', fileCount: 3, + }); + + function store(entries: Record) { + return new FakeFileStore(entries); + } + + it('accepts a well-formed extracted bundle', async () => { + const files = store({ + '/c/2026.05.0-179/bundle.json': manifest, + '/c/2026.05.0-179/llms.txt': '# Positron\n', + '/c/2026.05.0-179/welcome.llms.md': '# Welcome\n', + }); + expect(await validateExtractedBundle(files, '/c/2026.05.0-179')) + .toMatchObject({ ok: true, manifest: { version: '2026.05.0-179' } }); + }); + + it('does not count an empty directory as a file', async () => { + // Real extractors materialise explicit directory entries, so an empty + // directory inside a bundle is reachable. Counting it as a file would + // reject a perfectly good bundle with file-count-mismatch. + const files = store({ + '/c/2026.05.0-179/bundle.json': manifest, + '/c/2026.05.0-179/llms.txt': '# Positron\n', + '/c/2026.05.0-179/welcome.llms.md': '# Welcome\n', + }); + await files.mkdir('/c/2026.05.0-179/reference'); + + expect(await validateExtractedBundle(files, '/c/2026.05.0-179')).toMatchObject({ ok: true }); + }); + + it('rejects a missing bundle.json', async () => { + const files = store({ '/c/x/llms.txt': '# Positron\n' }); + expect(await validateExtractedBundle(files, '/c/x')).toMatchObject({ ok: false, reason: 'missing-manifest' }); + }); + + it('rejects a missing llms.txt', async () => { + const files = store({ '/c/x/bundle.json': manifest }); + expect(await validateExtractedBundle(files, '/c/x')).toMatchObject({ ok: false, reason: 'missing-index' }); + }); + + it('rejects schema 2', async () => { + const files = store({ + '/c/x/bundle.json': JSON.stringify({ ...JSON.parse(manifest), schema: 2 }), + '/c/x/llms.txt': '# Positron\n', + }); + expect(await validateExtractedBundle(files, '/c/x')).toMatchObject({ ok: false, reason: 'bad-manifest' }); + }); + + it('rejects a fileCount that does not match what was extracted', async () => { + const files = store({ + '/c/x/bundle.json': JSON.stringify({ ...JSON.parse(manifest), fileCount: 99 }), + '/c/x/llms.txt': '# Positron\n', + }); + expect(await validateExtractedBundle(files, '/c/x')).toMatchObject({ ok: false, reason: 'file-count-mismatch' }); + }); +});