diff --git a/crates/ask/src/commands/ensure_checkout.rs b/crates/ask/src/commands/ensure_checkout.rs index 5419f87..cf7dba1 100644 --- a/crates/ask/src/commands/ensure_checkout.rs +++ b/crates/ask/src/commands/ensure_checkout.rs @@ -253,14 +253,22 @@ pub fn ensure_checkout( .and_then(|r| r.store_path.clone()) .unwrap_or_else(|| checkout_dir.clone()); - // Keep `ref` consistent with the actual checkout dir (its basename), so the - // `ask src --json` contract never reports a ref that disagrees with the path. + // Keep `ref` consistent with the actual checkout dir. Prefer the + // fetcher's `meta.ref` (the real winning ref) over the checkout's + // basename — slash-containing tags like `@tanstack/react-query@5.101.2` + // are encoded (`/` → `__`) in the directory name, so the basename is + // only a fallback for fetchers that do not report `meta.ref`. let actual_ref = if resolved_checkout_dir == checkout_dir { reference } else { - resolved_checkout_dir - .file_name() - .map(|n| n.to_string_lossy().into_owned()) + fetch_result + .as_ref() + .and_then(|r| r.meta.ref_.clone()) + .or_else(|| { + resolved_checkout_dir + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + }) .unwrap_or(reference) }; diff --git a/crates/ask/src/resolvers/npm.rs b/crates/ask/src/resolvers/npm.rs index f0f0747..b8ac162 100644 --- a/crates/ask/src/resolvers/npm.rs +++ b/crates/ask/src/resolvers/npm.rs @@ -124,17 +124,22 @@ pub fn resolve( })?; // Monorepo packages (repository.directory present) use changesets-style - // `@` / `@v` tags; scoped names use the unscoped - // part (`@vercel/ai` → `ai`). + // `@` / `@v` tags. Scoped monorepos + // (`@tanstack/*`, `@trpc/*`, ...) tag with the FULL scoped name + // (`@tanstack/react-query@5.101.2`) — try that first, then the unscoped + // part (`react-query@5.101.2`) used by repos like `vercel/ai`. let mut fallback_refs = Vec::new(); if repo_field.and_then(RepoField::directory).is_some() { - let unscoped = if let Some(rest) = name.strip_prefix('@') { - rest.split_once('/').map(|(_, n)| n).unwrap_or(rest) + if let Some(rest) = name.strip_prefix('@') { + fallback_refs.push(format!("{name}@{resolved_version}")); + fallback_refs.push(format!("{name}@v{resolved_version}")); + let unscoped = rest.split_once('/').map(|(_, n)| n).unwrap_or(rest); + fallback_refs.push(format!("{unscoped}@{resolved_version}")); + fallback_refs.push(format!("{unscoped}@v{resolved_version}")); } else { - name - }; - fallback_refs.push(format!("{unscoped}@{resolved_version}")); - fallback_refs.push(format!("{unscoped}@v{resolved_version}")); + fallback_refs.push(format!("{name}@{resolved_version}")); + fallback_refs.push(format!("{name}@v{resolved_version}")); + } } fallback_refs.push(resolved_version.clone()); @@ -205,7 +210,49 @@ mod tests { let c = MockClient::new().with("https://registry.npmjs.org/@vercel/ai", 200, meta); let r = resolve(&c, "@vercel/ai", "latest").unwrap(); assert_eq!(r.repo, "vercel/ai"); - assert_eq!(r.fallback_refs, vec!["ai@5.0.0", "ai@v5.0.0", "5.0.0"]); + assert_eq!( + r.fallback_refs, + vec![ + "@vercel/ai@5.0.0", + "@vercel/ai@v5.0.0", + "ai@5.0.0", + "ai@v5.0.0", + "5.0.0" + ] + ); + } + + #[test] + fn monorepo_unscoped_package_has_no_scoped_fallbacks() { + let meta = r#"{ + "dist-tags": { "latest": "6.0.158" }, + "versions": { "6.0.158": {} }, + "repository": { "url": "https://github.com/vercel/ai.git", "directory": "packages/ai" } + }"#; + let c = MockClient::new().with("https://registry.npmjs.org/ai", 200, meta); + let r = resolve(&c, "ai", "latest").unwrap(); + assert_eq!( + r.fallback_refs, + vec!["ai@6.0.158", "ai@v6.0.158", "6.0.158"] + ); + } + + #[test] + fn scoped_monorepo_tag_candidate_matches_tanstack_convention() { + // pleaseai/ask#121: the real tag on TanStack/query is + // `@tanstack/react-query@5.101.2` (full scoped name, contains `/`). + let meta = r#"{ + "dist-tags": { "latest": "5.101.2" }, + "versions": { "5.101.2": {} }, + "repository": { "url": "https://github.com/TanStack/query.git", "directory": "packages/react-query" } + }"#; + let c = MockClient::new().with( + "https://registry.npmjs.org/@tanstack/react-query", + 200, + meta, + ); + let r = resolve(&c, "@tanstack/react-query", "latest").unwrap(); + assert_eq!(r.fallback_refs[0], "@tanstack/react-query@5.101.2"); } #[test] diff --git a/crates/ask/src/sources/github.rs b/crates/ask/src/sources/github.rs index 6f14378..33a2986 100644 --- a/crates/ask/src/sources/github.rs +++ b/crates/ask/src/sources/github.rs @@ -330,8 +330,12 @@ pub fn fetch( store_path: Some(store_dir), store_subpath: opts.docs_path.clone(), from_store_cache: true, + // Report the WINNING candidate, not the requested ref — the + // store dir is keyed by the candidate, and slash-containing + // tags are encoded in the path, so callers cannot recover + // the real ref from the directory basename. meta: FetchMeta { - ref_: Some(reference.clone()), + ref_: Some(candidate), ..Default::default() }, }); @@ -752,6 +756,52 @@ mod tests { ); } + #[test] + fn scoped_monorepo_tag_clones_into_encoded_store_dir() { + // pleaseai/ask#121: tags like `@tanstack/react-query@5.101.2` contain + // `/` — the clone must succeed and land under the encoded dir name, + // while meta.ref reports the REAL tag. + if !has_git() { + return; + } + let tmp = tempfile::tempdir().unwrap(); + let remote = create_local_remote(tmp.path()); + let scoped_tag = "@tanstack/react-query@5.101.2"; + git(&["tag", scoped_tag, "v1.0.0"], &tmp.path().join("work")); + // Re-push the new tag into the bare remote. + git( + &["push", remote.to_str().unwrap(), scoped_tag], + &tmp.path().join("work"), + ); + let ask_home = tmp.path().join("ask-home"); + let opts = GithubOptions { + name: "react-query".into(), + version: "5.101.2".into(), + repo: "TanStack/query".into(), + tag: Some("v5.101.2".into()), + fallback_refs: vec![scoped_tag.to_string()], + docs_path: Some("docs".into()), + remote_url: Some(remote.to_string_lossy().into_owned()), + ..Default::default() + }; + let result = fetch(&no_client(), &opts, &ask_home).unwrap(); + + let store_path = result.store_path.clone().unwrap(); + assert_eq!( + store_path.file_name().unwrap().to_str().unwrap(), + "@tanstack__react-query@5.101.2-8cd04c22" + ); + assert_eq!(result.meta.ref_.as_deref(), Some(scoped_tag)); + assert!(store_path.join("docs/guide.md").exists()); + + // Second fetch: store-cache hit on the same encoded path, still + // reporting the real tag. + let cached = fetch(&no_client(), &opts, &ask_home).unwrap(); + assert!(cached.from_store_cache); + assert_eq!(cached.store_path.as_deref(), Some(store_path.as_path())); + assert_eq!(cached.meta.ref_.as_deref(), Some(scoped_tag)); + } + #[test] fn second_fetch_hits_the_store_cache() { if !has_git() { diff --git a/crates/ask/src/store/paths.rs b/crates/ask/src/store/paths.rs index 8e52e9b..0d33dbb 100644 --- a/crates/ask/src/store/paths.rs +++ b/crates/ask/src/store/paths.rs @@ -95,6 +95,29 @@ pub fn npm_store_path(ask_home: &Path, pkg: &str, version: &str) -> anyhow::Resu assert_contained(ask_home, &candidate) } +/// Encode a git ref for use as a single store-path segment. Git refs may +/// legitimately contain `/` — scoped monorepo tags like +/// `@tanstack/react-query@5.101.2` and branch names like `release/v1.2.3` — +/// but the store layout maps each ref to exactly one directory name. +/// Separators become `__` (same convention as the skills spec-key encoding) +/// instead of being rejected; `..` and other traversal inputs are still +/// rejected by [`assert_safe_segment`] after encoding. +/// +/// The `__` substitution alone is not injective (`release/v1.0.0` and a +/// literal `release__v1.0.0` tag would share a cache dir, silently shadowing +/// each other), so separator-containing refs also get a short hash of the +/// ORIGINAL ref appended. Refs without separators — the overwhelmingly +/// common case — map to themselves unchanged. +pub fn encode_ref_segment(reference: &str) -> String { + if !reference.contains(['/', '\\']) { + return reference.to_string(); + } + let mut hasher = Sha256::new(); + hasher.update(reference.as_bytes()); + let hash = format!("{:x}", hasher.finalize()); + format!("{}-{}", reference.replace(['/', '\\'], "__"), &hash[..8]) +} + /// The PM-style nested layout for a github entry: /// `/github/////`. pub fn github_store_path( @@ -107,9 +130,16 @@ pub fn github_store_path( assert_safe_segment("host", host)?; assert_safe_segment("owner", owner)?; assert_safe_segment("repo", repo)?; - assert_safe_segment("tag", tag)?; + // Refs may contain `/` (scoped monorepo tags, `release/*` branches) — + // encode to a single directory name, then run the traversal guard. + let encoded_tag = encode_ref_segment(tag); + assert_safe_segment("tag", &encoded_tag)?; let github_root = ask_home.join("github"); - let candidate = github_root.join(host).join(owner).join(repo).join(tag); + let candidate = github_root + .join(host) + .join(owner) + .join(repo) + .join(&encoded_tag); assert_contained(&github_root, &candidate) } @@ -194,6 +224,45 @@ mod tests { assert!(github_store_path(home, "github.com", "o", "repo", "").is_err()); } + #[test] + fn github_path_encodes_slash_containing_refs() { + // pleaseai/ask#121: scoped monorepo tags and `release/*` branches + // contain `/` — encoded to `__` + 8-char sha256 of the original ref. + let home = Path::new("/store"); + assert_eq!( + github_store_path( + home, + "github.com", + "TanStack", + "query", + "@tanstack/react-query@5.101.2" + ) + .unwrap(), + PathBuf::from( + "/store/github/github.com/TanStack/query/@tanstack__react-query@5.101.2-8cd04c22" + ) + ); + assert_eq!( + github_store_path(home, "github.com", "o", "repo", "release/v1.2.3").unwrap(), + PathBuf::from("/store/github/github.com/o/repo/release__v1.2.3-3101f387") + ); + // Traversal hidden inside a slash-containing ref is still rejected. + assert!(github_store_path(home, "github.com", "o", "repo", "@scope/../../escape").is_err()); + } + + #[test] + fn encode_ref_segment_is_injective_for_underscore_twins() { + // A literal `release__v1.2.3` tag (no separator) maps to itself; + // `release/v1.2.3` carries a hash suffix — never the same dir. + assert_eq!(encode_ref_segment("release__v1.2.3"), "release__v1.2.3"); + assert_ne!( + encode_ref_segment("release__v1.2.3"), + encode_ref_segment("release/v1.2.3") + ); + // Plain tags are unchanged. + assert_eq!(encode_ref_segment("v1.0.0"), "v1.0.0"); + } + #[test] fn assert_contained_blocks_escape() { let parent = Path::new("/store/github"); diff --git a/packages/cli/src/commands/ensure-checkout.ts b/packages/cli/src/commands/ensure-checkout.ts index fbf4bc1..7d89ff8 100644 --- a/packages/cli/src/commands/ensure-checkout.ts +++ b/packages/cli/src/commands/ensure-checkout.ts @@ -257,12 +257,14 @@ export async function ensureCheckout( const resolvedCheckoutDir = fetchResult?.storePath ?? checkoutDir // When a fallbackRef (or a `v`-rescued ref) wins, the store dir lives - // under the WINNING ref, not the requested one. The store layout ends - // in `...////`, so the actual ref is the checkout's - // basename. Keep `ref` consistent with `checkoutDir` — otherwise the - // `ask src --json` contract would report a `ref` that disagrees with - // the path downstream tools actually index. - const actualRef = resolvedCheckoutDir === checkoutDir ? ref : path.basename(resolvedCheckoutDir) + // under the WINNING ref, not the requested one. Prefer the fetcher's + // `meta.ref` (the real winning ref) over the checkout's basename — + // slash-containing tags like `@tanstack/react-query@5.101.2` are + // encoded (`/` → `__`) in the directory name, so the basename is only + // a fallback for fetchers that do not report `meta.ref`. + const actualRef = resolvedCheckoutDir === checkoutDir + ? ref + : fetchResult?.meta?.ref ?? path.basename(resolvedCheckoutDir) // `GithubSource.fetch` can satisfy the request from its own store-hit // path (a ref-candidate variant like `v` or `master`) with zero diff --git a/packages/cli/src/resolvers/npm.ts b/packages/cli/src/resolvers/npm.ts index daba5ea..7c50d1a 100644 --- a/packages/cli/src/resolvers/npm.ts +++ b/packages/cli/src/resolvers/npm.ts @@ -84,9 +84,14 @@ export class NpmResolver implements EcosystemResolver { // For monorepo packages (those with repository.directory), prepend pkg-name@version tags. // Changesets convention uses `@` and `@v`. - // Scoped packages like `@vercel/ai` use only the unscoped part (e.g. `ai`). + // Scoped monorepos (`@tanstack/*`, `@trpc/*`, ...) tag with the FULL scoped + // name (`@tanstack/react-query@5.101.2`) — try that first, then the + // unscoped part (`react-query@5.101.2`) used by repos like `vercel/ai`. const monorepoFallbacks: string[] = [] if (typeof repoField === 'object' && repoField?.directory) { + if (name.startsWith('@')) { + monorepoFallbacks.push(`${name}@${resolvedVersion}`, `${name}@v${resolvedVersion}`) + } const unscopedName = name.startsWith('@') ? name.split('/')[1] : name monorepoFallbacks.push(`${unscopedName}@${resolvedVersion}`, `${unscopedName}@v${resolvedVersion}`) } diff --git a/packages/cli/src/sources/github.ts b/packages/cli/src/sources/github.ts index 7a320b3..56ca88b 100644 --- a/packages/cli/src/sources/github.ts +++ b/packages/cli/src/sources/github.ts @@ -353,7 +353,11 @@ export class GithubSource implements DocSource { storePath: storeDir, storeSubpath: docsPath, fromStoreCache: true, - meta: { ref }, + // Report the WINNING candidate, not the requested ref — the + // store dir is keyed by the candidate, and slash-containing + // tags are encoded in the path, so callers cannot recover the + // real ref from the directory basename. + meta: { ref: candidate }, } } quarantineEntry(askHome, storeDir) diff --git a/packages/cli/src/store/index.ts b/packages/cli/src/store/index.ts index a4a8d16..91db46c 100644 --- a/packages/cli/src/store/index.ts +++ b/packages/cli/src/store/index.ts @@ -95,6 +95,30 @@ function assertSafeSegment(name: string, value: string): void { } } +const RE_PATH_SEPARATORS = /[/\\]/g + +/** + * Encode a git ref for use as a single store-path segment. Git refs may + * legitimately contain `/` — scoped monorepo tags like + * `@tanstack/react-query@5.101.2` and branch names like `release/v1.2.3` + * — but the store layout maps each ref to exactly one directory name. + * Separators become `__` (same convention as the skills spec-key + * encoding) instead of being rejected; `..` and other traversal inputs + * are still rejected by `assertSafeSegment` after encoding. + * + * The `__` substitution alone is not injective (`release/v1.0.0` and a + * literal `release__v1.0.0` tag would share a cache dir, silently + * shadowing each other), so separator-containing refs also get a short + * hash of the ORIGINAL ref appended. Refs without separators — the + * overwhelmingly common case — map to themselves unchanged. + */ +export function encodeRefSegment(ref: string): string { + if (!ref.includes('/') && !ref.includes('\\')) + return ref + const hash = crypto.createHash('sha256').update(ref).digest('hex').slice(0, 8) + return `${ref.replace(RE_PATH_SEPARATORS, '__')}-${hash}` +} + export function githubStorePath( askHome: string, host: string, @@ -105,9 +129,12 @@ export function githubStorePath( assertSafeSegment('host', host) assertSafeSegment('owner', owner) assertSafeSegment('repo', repo) - assertSafeSegment('tag', tag) + // Refs may contain `/` (scoped monorepo tags, `release/*` branches) — + // encode to a single directory name, then run the traversal guard. + const encodedTag = encodeRefSegment(tag) + assertSafeSegment('tag', encodedTag) const githubRoot = path.join(askHome, 'github') - const candidate = path.join(githubRoot, host, owner, repo, tag) + const candidate = path.join(githubRoot, host, owner, repo, encodedTag) return assertContained(githubRoot, candidate) } diff --git a/packages/cli/test/resolvers/npm.test.ts b/packages/cli/test/resolvers/npm.test.ts index 9da0eb6..f0e525a 100644 --- a/packages/cli/test/resolvers/npm.test.ts +++ b/packages/cli/test/resolvers/npm.test.ts @@ -162,7 +162,7 @@ describe('NpmResolver', () => { expect(result.fallbackRefs).toEqual(['4.17.21']) }) - it('uses unscoped name for scoped packages like @vercel/ai', async () => { + it('tries the full scoped name before the unscoped name for scoped packages', async () => { mockFetchJson({ 'repository': { type: 'git', @@ -174,11 +174,29 @@ describe('NpmResolver', () => { }) const result = await resolver.resolve('@vercel/ai', '6.0.158') - // unscoped name is 'ai' - expect(result.fallbackRefs).toContain('ai@6.0.158') - expect(result.fallbackRefs).toContain('ai@v6.0.158') - // scoped form should NOT appear - expect(result.fallbackRefs!.every(r => !r.startsWith('@'))).toBe(true) + expect(result.fallbackRefs).toEqual([ + '@vercel/ai@6.0.158', + '@vercel/ai@v6.0.158', + 'ai@6.0.158', + 'ai@v6.0.158', + '6.0.158', + ]) + }) + + it('generates scoped monorepo tags for @tanstack/react-query (#121)', async () => { + mockFetchJson({ + 'repository': { + type: 'git', + url: 'git+https://github.com/TanStack/query.git', + directory: 'packages/react-query', + }, + 'dist-tags': { latest: '5.101.2' }, + 'versions': { '5.101.2': {} }, + }) + + const result = await resolver.resolve('@tanstack/react-query', '5.101.2') + // The real tag on TanStack/query is `@tanstack/react-query@5.101.2`. + expect(result.fallbackRefs![0]).toBe('@tanstack/react-query@5.101.2') }) }) }) diff --git a/packages/cli/test/sources/github-monorepo.test.ts b/packages/cli/test/sources/github-monorepo.test.ts index 29de3a6..d20cbed 100644 --- a/packages/cli/test/sources/github-monorepo.test.ts +++ b/packages/cli/test/sources/github-monorepo.test.ts @@ -180,7 +180,7 @@ describe('integration: full pipeline — NpmResolver + GithubSource', () => { expect(fs.existsSync(path.join(fetchResult.storePath!, 'docs', 'api.md'))).toBe(true) }) - it('scoped package @vercel/ai: resolver uses unscoped name "ai" in fallbackRefs', async () => { + it('scoped package @vercel/ai: unscoped tag wins when the repo only ships unscoped tags', async () => { // Mock npm registry for @vercel/ai with repository.directory mockNpmFetch({ 'repository': { @@ -195,13 +195,12 @@ describe('integration: full pipeline — NpmResolver + GithubSource', () => { const resolver = new NpmResolver() const resolveResult = await resolver.resolve('@vercel/ai', '6.0.158') - // Unscoped name must be used — NOT @vercel/ai@6.0.158 + // Both scoped and unscoped candidates, scoped first + expect(resolveResult.fallbackRefs).toContain('@vercel/ai@6.0.158') expect(resolveResult.fallbackRefs).toContain('ai@6.0.158') expect(resolveResult.fallbackRefs).toContain('ai@v6.0.158') - // Scoped form must NOT appear - expect(resolveResult.fallbackRefs!.every(r => !r.startsWith('@'))).toBe(true) - // Verify the unscoped tag pattern works with GithubSource + // The remote only has the unscoped tag — the chain must fall through to it const remoteUrl = createMonorepoRemote('ai@6.0.158') const source = new GithubSource() const fetchResult = await source.fetch({ @@ -218,4 +217,57 @@ describe('integration: full pipeline — NpmResolver + GithubSource', () => { expect(fetchResult.storePath).toContain('ai@6.0.158') expect(fetchResult.resolvedVersion).toBe('6.0.158') }) + + it('scoped monorepo tag @tanstack/react-query@5.101.2: clone lands in the encoded store dir (#121)', async () => { + const scopedTag = '@tanstack/react-query@5.101.2' + const remoteUrl = createMonorepoRemote(scopedTag) + + mockNpmFetch({ + 'repository': { + type: 'git', + url: 'git+https://github.com/TanStack/query.git', + directory: 'packages/react-query', + }, + 'dist-tags': { latest: '5.101.2' }, + 'versions': { '5.101.2': {} }, + }) + + const resolver = new NpmResolver() + const resolveResult = await resolver.resolve('@tanstack/react-query', '5.101.2') + expect(resolveResult.fallbackRefs![0]).toBe(scopedTag) + + const source = new GithubSource() + const fetchResult = await source.fetch({ + source: 'github', + name: 'react-query', + version: resolveResult.resolvedVersion, + repo: resolveResult.repo, + tag: resolveResult.ref, + docsPath: 'docs', + fallbackRefs: resolveResult.fallbackRefs, + remoteUrl, + } as any) + + // Slash in the tag is encoded (`__` + short hash of the real ref) + expect(path.basename(fetchResult.storePath!)).toBe('@tanstack__react-query@5.101.2-8cd04c22') + // ...but meta.ref reports the REAL winning tag + expect(fetchResult.meta?.ref).toBe(scopedTag) + expect(fs.existsSync(path.join(fetchResult.storePath!, 'docs', 'intro.md'))).toBe(true) + + // Second fetch is a store-cache hit on the same encoded path, + // still reporting the real tag. + const cached = await source.fetch({ + source: 'github', + name: 'react-query', + version: resolveResult.resolvedVersion, + repo: resolveResult.repo, + tag: resolveResult.ref, + docsPath: 'docs', + fallbackRefs: resolveResult.fallbackRefs, + remoteUrl, + } as any) + expect(cached.fromStoreCache).toBe(true) + expect(cached.storePath).toBe(fetchResult.storePath!) + expect(cached.meta?.ref).toBe(scopedTag) + }) }) diff --git a/packages/cli/test/store/index.test.ts b/packages/cli/test/store/index.test.ts index 8719dd1..1b9b554 100644 --- a/packages/cli/test/store/index.test.ts +++ b/packages/cli/test/store/index.test.ts @@ -95,6 +95,32 @@ describe('path helpers', () => { .toThrow(/Unsafe path/) }) + it('githubStorePath encodes slash-containing scoped monorepo tags (#121)', () => { + // `__` substitution + 8-char sha256 of the original ref (injectivity) + expect(githubStorePath(home, 'github.com', 'TanStack', 'query', '@tanstack/react-query@5.101.2')) + .toBe('/home/user/.ask/github/github.com/TanStack/query/@tanstack__react-query@5.101.2-8cd04c22') + }) + + it('githubStorePath encodes slash-containing branch refs', () => { + expect(githubStorePath(home, 'github.com', 'foo', 'bar', 'release/v1.2.3')) + .toBe('/home/user/.ask/github/github.com/foo/bar/release__v1.2.3-3101f387') + }) + + it('githubStorePath keeps a literal __ tag distinct from its slash twin', () => { + // `release__v1.2.3` (no separator) maps to itself; `release/v1.2.3` + // carries a hash suffix — the two never share a cache dir. + expect(githubStorePath(home, 'github.com', 'foo', 'bar', 'release__v1.2.3')) + .toBe('/home/user/.ask/github/github.com/foo/bar/release__v1.2.3') + expect(githubStorePath(home, 'github.com', 'foo', 'bar', 'release__v1.2.3')) + .not + .toBe(githubStorePath(home, 'github.com', 'foo', 'bar', 'release/v1.2.3')) + }) + + it('githubStorePath still rejects traversal hidden inside a slash-containing tag', () => { + expect(() => githubStorePath(home, 'github.com', 'foo', 'bar', '@scope/../../escape')) + .toThrow(/Unsafe path/) + }) + it('githubStorePath rejects path traversal via host', () => { expect(() => githubStorePath(home, '../malicious', 'foo', 'bar', 'v1.0.0')) .toThrow(/Unsafe path/)