diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index 03436ca5ff..baeb09ad3d 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -11,6 +11,41 @@ const { runtimeHostSetupPackage } = resolveProductManifestIdentity({ cliManifest: readManifest('../../packages/cli/package.json'), }); +// Git Credential Manager and the .NET runtime it needs are 223 files and +// ~103 MiB of the bundled Git distribution, and Maka never runs them: every +// git invocation sets `credential.helper=` explicitly +// (`git-workspace-service.ts:1257`, `:2873`), so the helper is not merely +// unused — it is switched off at the call site. Credentials live in Maka's +// own `credentials.json` under the SECURITY.md permission contract. +// +// Excluded by name because the payload is interleaved with git's own commands +// inside one flat `libexec/git-core` — there is no directory to drop. `.dll` +// and `.dylib` appear nowhere else in the distribution, and every one of the +// 16 dylibs is a .NET, Avalonia or Skia runtime library. +// `**/*` rather than `*`: the runtime is not flat. Alongside the assemblies at +// the top level, GCM ships 13 localisation directories each holding one +// `System.CommandLine.resources.dll`, and on Linux two native UI libraries +// (`libSkiaSharp.so`, `libHarfBuzzSharp.so`). A top-level-only glob leaves all +// of them behind — which it did, until a review caught it against a real +// package. +// +// `.so` is listed for the Linux distribution, where GCM is self-contained and +// its Skia/HarfBuzz libraries sit beside the binary. Git's own commands in +// this directory are executables and shell scripts, never `.dll`/`.dylib`/ +// `.so`, so the extension globs cannot reach them. +const GIT_CREDENTIAL_MANAGER_EXCLUDES = [ + '!libexec/git-core/**/*.dll', + '!libexec/git-core/**/*.dylib', + '!libexec/git-core/**/*.so', + '!libexec/git-core/git-credential-manager*', + '!libexec/git-core/createdump', + // GCM's own installation leftovers. `NOTICE` is its third-party attribution + // file, not git's — git's own notices live in `share/doc`, which this filter + // does not touch. + '!libexec/git-core/NOTICE', + '!libexec/git-core/uninstall.sh', +]; + export default { appId: 'com.maka.desktop', productName: 'Maka', @@ -50,6 +85,7 @@ export default { { from: '../../node_modules/dugite/git', to: 'git', + filter: ['**/*', ...GIT_CREDENTIAL_MANAGER_EXCLUDES], }, { from: 'bundled-git.json', diff --git a/scripts/verify-packaged-app.mjs b/scripts/verify-packaged-app.mjs index ab4446efb7..f1481a79c9 100644 --- a/scripts/verify-packaged-app.mjs +++ b/scripts/verify-packaged-app.mjs @@ -4,7 +4,7 @@ import { createReadStream, readFileSync } from 'node:fs'; import { access, mkdir, readFile, readdir } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { createServer } from 'node:net'; -import { join, resolve, sep } from 'node:path'; +import { join, relative, resolve, sep } from 'node:path'; import { ASSET_LICENSED_RENDERER_PACKAGES, collectProductionClosure, @@ -705,6 +705,13 @@ export async function assertPackagedResources( requirePath, forbidPath = assertMissing, requireWindowsSandbox = process.platform === 'win32', + // The credential-manager exclusion is written against the POSIX Git + // distribution, whose commands sit in a flat `libexec/git-core`. The + // Windows distribution has a different layout (`git/cmd/git.exe`, and its + // own `.dll` set that git itself loads), so the exclusion does not apply + // there and neither does this assertion. Scoped rather than guessed: + // trimming Windows needs its own measurement first. + assertGitTree = process.platform !== 'win32', // The upgrade-lifecycle check runs this against a previously released // build, which predates the disclaimer being packaged. Requiring it there // would fail a release that was correct when it shipped. @@ -759,10 +766,83 @@ export async function assertPackagedResources( // app, and `distributionReady` is false for exactly this reason. join('bin', 'maka-cu'), join('tools', 'maka-cu'), + // Git Credential Manager and its .NET runtime are excluded from the + // packaged Git distribution: Maka sets `credential.helper=` on every git + // invocation, so nothing can reach them. Naming the entry point rather + // than the runtime keeps this readable; `assertPackagedGitIsComplete` + // covers the rest by listing the directory. + ...(assertGitTree ? [join('git', 'libexec', 'git-core', 'git-credential-manager')] : []), ]; for (const path of forbidden) { await forbidPath(join(resourcesPath, path)); } + if (assertGitTree) await assertPackagedGitIsComplete(resourcesPath, requirePath); +} + +/** + * The credential-manager exclusion is a name filter over one flat directory + * that also holds git's own commands, so it can over-match — and a git that + * lost `git-remote-http` fails at clone time in a user's hands, not here. + * + * Both halves are asserted: the commands Maka actually invokes must be + * present, and nothing from the .NET runtime may be. Checking only the + * absence would pass just as well for an empty directory. + */ +async function assertPackagedGitIsComplete(resourcesPath, requirePath) { + const gitCore = join(resourcesPath, 'git', 'libexec', 'git-core'); + // `git-workspace-service.ts` drives add / cat-file / commit / config / + // for-each-ref / init / rev-parse / status / worktree. Those are builtins + // reached through the `git` binary; what has to exist on disk is the + // binary itself plus the helpers git dispatches to as separate programs. + for (const name of ['git', 'git-remote-http', 'git-http-fetch', 'git-shell']) { + await requirePath(join(gitCore, name)); + } + await requirePath(join(resourcesPath, 'git', 'bin', 'git')); + await requirePath(join(resourcesPath, 'git', 'share', 'git-core', 'templates')); + + // Recursive, because the runtime is not flat: GCM ships 13 localisation + // directories each holding one `System.CommandLine.resources.dll`, and on + // Linux two native UI libraries beside the binary. A top-level-only scan + // reported a clean tree while all of that was still packaged — which is + // exactly what it did until a review checked a real artifact. + const runtimeLeftovers = await findCredentialManagerLeftovers(gitCore, gitCore); + if (runtimeLeftovers.length > 0) { + throw new Error( + `packaged git still carries the credential-manager runtime: ${runtimeLeftovers.join(', ')}`, + ); + } +} + +/** + * Every credential-manager artefact under one directory, as paths relative to + * the git-core root so the failure names where to look. + * + * Git's own commands here are executables and shell scripts, so matching on + * the .NET/native extensions cannot implicate them. `NOTICE` and + * `uninstall.sh` are GCM's installation leftovers, not git's — git keeps its + * notices in `share/doc`. + */ +async function findCredentialManagerLeftovers(directory, root) { + const leftovers = []; + const entries = await readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + const absolute = join(directory, entry.name); + if (entry.isDirectory()) { + leftovers.push(...(await findCredentialManagerLeftovers(absolute, root))); + continue; + } + if (!entry.isFile()) continue; + const isRuntimeLibrary = /\.(?:dll|dylib|so)$/u.test(entry.name); + const isCredentialManager = + entry.name.startsWith('git-credential-manager') || + entry.name === 'createdump' || + entry.name === 'NOTICE' || + entry.name === 'uninstall.sh'; + if (isRuntimeLibrary || isCredentialManager) { + leftovers.push(relative(root, absolute)); + } + } + return leftovers; } /**