dsh-config-manager 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,84 @@
1
+ import { readdir, rm } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * issue #120:**不依赖 shell、不依赖可执行文件**的 git 认证兜底。
6
+ *
7
+ * 真机现场(Android,App 内嵌 `dsh web`):git 二进制编译期把 GIT_SHELL_PATH 钉成
8
+ * Termux 的 `/data/data/com.termux/files/usr/bin/sh`,该路径在 App 沙箱里不存在,而 git 执行
9
+ * credential helper 的方式是把 helper 值拼成 `sh -c 'git <helper> ...'` —— **没有**「值里不含空格
10
+ * 就不经 shell」这种写法(本机 git trace 实测:`run_command: 'git credential-store get'` 之后紧接
11
+ * `start_command: '<git>/usr/bin/sh.exe' -c 'git credential-store get'`;裸 `credential.helper=store`
12
+ * 也一样)。于是 helper 恒定失败:
13
+ * `fatal: cannot exec 'git credential-store --file=... credential get': No such file or directory`
14
+ * → 无 tty 时退化成 `fatal: could not read Password for 'https://oauth2@github.com': terminal prompts disabled`。
15
+ *
16
+ * 换成 `core.askpass` 躲不开:askpass 是一个**要被 exec 的脚本**,而 Android 10 起 targetSdk ≥ 29
17
+ * 的应用不允许执行自己数据目录里的文件(SELinux untrusted_app_29+ 对 app_data_file 无 execute 权限)。
18
+ *
19
+ * 唯一不需要 exec 的通道是 git 自己从环境变量读配置(git ≥ 2.31 的 GIT_CONFIG_COUNT 协议):
20
+ * 把 `http.<scope>.extraheader = AUTHORIZATION: basic <base64(user:token)>` 挂进 git 子进程的 env,
21
+ * 凭据既不进 argv、也不落临时文件、更不需要 sh —— token 只出现在 git 子进程的环境里
22
+ * (Android 上 /proc/<pid>/environ 也只有同 uid 可读,比临时凭据文件更窄)。
23
+ *
24
+ * 用法:helper 路线先跑;失败且命中 isCredentialExecFailure() 时换这份 env 重试一次。
25
+ */
26
+
27
+ /**
28
+ * credential helper 无法被执行时的 git 原文特征:
29
+ * - `cannot exec ...: No such file or directory`:shell 或 helper 二进制不存在;
30
+ * - `could not read Password ...` / `terminal prompts disabled`:helper 没跑成,退化成读终端口令。
31
+ */
32
+ const CREDENTIAL_EXEC_FAILURE_RE = /cannot exec|could not read Password|terminal prompts disabled/i;
33
+
34
+ /** 这条 git 失败是不是「credential helper 根本没能执行」(→ 值得改用 HTTP 认证头重试)。 */
35
+ export function isCredentialExecFailure(text: string): boolean {
36
+ return CREDENTIAL_EXEC_FAILURE_RE.test(text);
37
+ }
38
+
39
+ /** 目录是否为空(读不到 = 非空:拿不准时绝不删东西,见 runGit 的 clone 重试)。 */
40
+ export async function isEmptyDir(dir: string): Promise<boolean> {
41
+ try {
42
+ return (await readdir(dir)).length === 0;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ /** 清空目录内容但保留目录本身(只用于「命令开跑前确认为空」的 clone 重试路径)。 */
49
+ export async function clearDirContents(dir: string): Promise<void> {
50
+ const entries = await readdir(dir).catch(() => [] as string[]);
51
+ for (const entry of entries) {
52
+ await rm(join(dir, entry), { recursive: true, force: true });
53
+ }
54
+ }
55
+
56
+ /**
57
+ * 构造「用 HTTP 认证头代替 credential helper」的子进程环境变量。
58
+ *
59
+ * - 顺带把 `credential.helper` 置空:否则全局 `.gitconfig` 里配的 helper 仍会被调用并再次 cannot exec。
60
+ * - 明文 http 常被远端重定向到 https,而 git 是按**每条请求的 URL** 匹配配置的 → 两个前缀都挂一份。
61
+ * - 返回空对象 = 注入不了(repoUrl 不是合法 URL,或令牌为空),调用方保持原有失败路径。
62
+ */
63
+ export function credentialHeaderEnv(repoUrl: string, username: string, token: string): Record<string, string> {
64
+ if (token === '') return {};
65
+ let origin: string;
66
+ try {
67
+ const u = new URL(repoUrl);
68
+ // 只取 scheme://host[:port]/ 作作用域:路径无关(同主机全部仓库都用这份令牌)
69
+ origin = `${u.protocol}//${u.host}/`;
70
+ } catch {
71
+ return {};
72
+ }
73
+ const scopes = [origin];
74
+ if (origin.startsWith('http://')) scopes.push(`https://${origin.slice('http://'.length)}`);
75
+ const authorization = `AUTHORIZATION: basic ${Buffer.from(`${username}:${token}`, 'utf8').toString('base64')}`;
76
+ const pairs: Array<[string, string]> = [['credential.helper', '']];
77
+ for (const scope of scopes) pairs.push([`http.${scope}.extraheader`, authorization]);
78
+ const env: Record<string, string> = { GIT_CONFIG_COUNT: String(pairs.length) };
79
+ pairs.forEach(([key, value], index) => {
80
+ env[`GIT_CONFIG_KEY_${String(index)}`] = key;
81
+ env[`GIT_CONFIG_VALUE_${String(index)}`] = value;
82
+ });
83
+ return env;
84
+ }
@@ -401,6 +401,102 @@ test('推送鉴权失败(GitHub 文案)→ 分类 auth + 可操作提示,
401
401
  );
402
402
  });
403
403
 
404
+ test('issue #120:credential helper 无法执行(Android 没有可用 shell)→ 改用 env 注入认证头重试一次', async (t) => {
405
+ const dir = await makeGitWorkDir(t);
406
+ const calls: { args: string[]; env?: Record<string, string> }[] = [];
407
+ const exec: GitExecFn = async (_cmd, args, opts) => {
408
+ calls.push({ args, env: opts.env });
409
+ const joined = args.join(' ');
410
+ if (joined.includes('diff') && joined.includes('--quiet')) return { stdout: '', stderr: '', code: 1 };
411
+ if (joined.includes('push -u')) {
412
+ // 第一次:credential helper 走 shell,而本机没有可用的 sh(真机原文)
413
+ if (opts.env === undefined || opts.env['GIT_CONFIG_KEY_0'] === undefined) {
414
+ return {
415
+ stdout: '',
416
+ stderr: "fatal: cannot exec 'git credential-store --file=/data/user/0/x/cache/tmp/dsh-git-cred-1/credential get': No such file or directory",
417
+ code: 128,
418
+ };
419
+ }
420
+ return { stdout: '', stderr: '', code: 0 };
421
+ }
422
+ return { stdout: '', stderr: '', code: 0 };
423
+ };
424
+ const transport = new GitTransport(makeOptions({ workDir: dir, exec }));
425
+ await transport.upload(sampleSnapshot());
426
+ const pushes = calls.filter((c) => c.args.join(' ').includes('push -u'));
427
+ assert.equal(pushes.length, 2, 'helper 无法执行时必须重试一次');
428
+ const retry = pushes[1]!;
429
+ assert.ok(!retry.args.some((a) => a.startsWith('credential.helper=store')), '重试不得再注入 store helper');
430
+ const env = retry.env!;
431
+ assert.equal(env['GIT_CONFIG_COUNT'], '2', '一条清 helper、一条挂认证头');
432
+ assert.equal(env['GIT_CONFIG_KEY_0'], 'credential.helper');
433
+ assert.equal(env['GIT_CONFIG_VALUE_0'], '', '必须清掉全局 helper,否则它会被再次调用并再次 cannot exec');
434
+ assert.equal(env['GIT_CONFIG_KEY_1'], 'http.https://github.com/.extraheader');
435
+ assert.equal(
436
+ env['GIT_CONFIG_VALUE_1'],
437
+ 'AUTHORIZATION: basic ' + Buffer.from('oauth2:' + TEST_TOKEN, 'utf8').toString('base64'),
438
+ );
439
+ for (const arg of retry.args) assert.ok(!arg.includes(TEST_TOKEN), '重试的 argv 不得含 token');
440
+ });
441
+
442
+ test('issue #120:认证头重试也失败 → 错误点名平台原因,不再把用户引向「令牌坏了」', async (t) => {
443
+ const dir = await makeGitWorkDir(t);
444
+ const calls: CallRecord[] = [];
445
+ const exec: GitExecFn = async (_cmd, args) => {
446
+ calls.push({ cmd: 'git', args });
447
+ const joined = args.join(' ');
448
+ if (joined.includes('diff') && joined.includes('--quiet')) return { stdout: '', stderr: '', code: 1 };
449
+ if (joined.includes('push -u')) {
450
+ return {
451
+ stdout: '',
452
+ stderr: "fatal: cannot exec 'git credential-store get': No such file or directory\n"
453
+ + 'fatal: could not read Password for ' + "'" + 'https://oauth2@github.com' + "'" + ': terminal prompts disabled',
454
+ code: 128,
455
+ };
456
+ }
457
+ return { stdout: '', stderr: '', code: 0 };
458
+ };
459
+ const transport = new GitTransport(makeOptions({ workDir: dir, exec }));
460
+ await assert.rejects(
461
+ transport.upload(sampleSnapshot()),
462
+ (err: unknown) => {
463
+ assert.ok(err instanceof GitTransportError);
464
+ assert.equal(err.kind, 'auth');
465
+ assert.match(err.message, /credential helper/, '必须点名 credential helper 执行不了');
466
+ assert.match(err.message, /2\.31/, '必须给出可验证的下限(env 注入配置需要 git ≥ 2.31)');
467
+ assert.ok(!/推送认证失败/.test(err.message), '不得把平台问题写成「令牌/权限」问题');
468
+ assert.ok(!err.message.includes(TEST_TOKEN), '错误消息不得泄漏 token');
469
+ return true;
470
+ },
471
+ );
472
+ assert.equal(calls.filter((c) => c.args.join(' ').includes('push -u')).length, 2, '只重试一次,不无限重试');
473
+ });
474
+
475
+ test('issue #120:clone 遇到 helper 无法执行 → 清掉半成品目录后用认证头重试成功', async (t) => {
476
+ const dir = await makeTempDir(t); // 空目录 → 走 clone 分支
477
+ let cloneAttempts = 0;
478
+ const exec: GitExecFn = async (_cmd, args, opts) => {
479
+ const joined = args.join(' ');
480
+ if (joined.includes('clone')) {
481
+ cloneAttempts += 1;
482
+ if (cloneAttempts === 1) {
483
+ // git 认证失败前已建出半成品(.git + 部分对象)
484
+ await fs.mkdir(path.join(dir, '.git'), { recursive: true });
485
+ await fs.writeFile(path.join(dir, '.git', 'partial'), 'half');
486
+ return { stdout: '', stderr: "fatal: cannot exec 'git credential-store get': No such file or directory", code: 128 };
487
+ }
488
+ assert.deepEqual(await fs.readdir(dir), [], '重试前必须清掉半成品,否则 clone 会因目录非空失败');
489
+ assert.ok(opts.env !== undefined, '重试必须走 env 注入认证头');
490
+ await fs.mkdir(path.join(dir, '.git'), { recursive: true });
491
+ return { stdout: '', stderr: '', code: 0 };
492
+ }
493
+ return { stdout: '', stderr: '', code: 0 };
494
+ };
495
+ const transport = new GitTransport(makeOptions({ workDir: dir, exec }));
496
+ await transport.list();
497
+ assert.equal(cloneAttempts, 2, '应恰好重试一次 clone');
498
+ });
499
+
404
500
  test('checkIsPrivate:匿名可达 → false;匿名失败+认证可达 → true;都失败 → 抛错', async (t) => {
405
501
  // 匿名成功
406
502
  const c1: CallRecord[] = [];
@@ -40,6 +40,7 @@ import type {
40
40
  import { parseJsonSafe } from '../../utils/json.ts';
41
41
  import { atomicWriteFile } from '../../utils/atomic-write.ts';
42
42
  import { quoteGitValue } from '../../utils/git-quote.ts';
43
+ import { clearDirContents, credentialHeaderEnv, isEmptyDir, isCredentialExecFailure } from './credential-auth.ts';
43
44
  import { SECTION_FILE_PREFIXES } from '../../schema/config.ts';
44
45
  import type { FilesSection, SectionData, SectionId } from '../../schema/types.ts';
45
46
  import { zhMsg } from '../../core/messages.ts';
@@ -60,7 +61,7 @@ export interface GitExecResult {
60
61
  export type GitExecFn = (
61
62
  cmd: string,
62
63
  args: string[],
63
- opts: { cwd?: string; timeoutMs?: number },
64
+ opts: { cwd?: string; timeoutMs?: number; env?: Record<string, string> },
64
65
  ) => Promise<GitExecResult>;
65
66
 
66
67
  /** 工作副本自检事实(issue #79):origin 是否已跟随配置地址、有无未上行的本地提交。 */
@@ -208,6 +209,9 @@ const defaultExec: GitExecFn = async (cmd, args, opts) => {
208
209
  maxBuffer: 16 * 1024 * 1024,
209
210
  windowsHide: true,
210
211
  encoding: 'utf8',
212
+ // issue #120:credential helper 走 shell 且本机没有可用 shell 时,改用 env 注入认证头重试 ——
213
+ // 追加在继承的 process.env 上(env 缺省 = 完全继承,不改变既有行为)
214
+ env: opts.env === undefined ? undefined : { ...process.env, ...opts.env },
211
215
  });
212
216
  return { stdout: String(stdout), stderr: String(stderr), code: 0 };
213
217
  } catch (err) {
@@ -948,8 +952,23 @@ export class GitTransport implements SyncTransport {
948
952
  if (cleanup) await cleanup();
949
953
  throw new GitTransportError(this.msg('sync.git.pushTokenRequired'), { kind: 'auth', retryable: false });
950
954
  }
955
+ // issue #120:只有「命令开跑前目录是空的」才敢在重试前清空 —— clone 失败可能留下半成品,
956
+ // 而目录里原本就有别的内容时宁可放弃重试,也绝不删用户文件。
957
+ const cloneTargetWasEmpty = args[0] === 'clone' ? await isEmptyDir(cwd) : false;
951
958
  try {
952
- const result = await this.o.exec(this.o.gitBin, [...extra, ...args], { cwd, timeoutMs: this.o.timeoutMs });
959
+ let result = await this.o.exec(this.o.gitBin, [...extra, ...args], { cwd, timeoutMs: this.o.timeoutMs });
960
+ // issue #120:credential helper 必须经 shell 执行(值里不含空格也一样,git 统一拼成
961
+ // `sh -c 'git <helper> ...'`),而 Android 上 git 二进制的 GIT_SHELL_PATH 指向 App 沙箱里
962
+ // 不存在的 Termux sh;`core.askpass` 又要 exec 权限(Android 10+ 对 targetSdk ≥ 29 禁执行应用
963
+ // 目录里的文件)。两条路都走不通时,改用 env 注入 HTTP 认证头重试一次(不需要 shell)。
964
+ if (result.code !== 0 && token !== null && token !== ''
965
+ && isCredentialExecFailure(result.stderr + ' ' + result.stdout)) {
966
+ const headerEnv = credentialHeaderEnv(this.o.repoUrl, this.o.credentialUsername, token);
967
+ if (Object.keys(headerEnv).length > 0 && (args[0] !== 'clone' || cloneTargetWasEmpty)) {
968
+ if (args[0] === 'clone') await clearDirContents(cwd);
969
+ result = await this.o.exec(this.o.gitBin, args, { cwd, timeoutMs: this.o.timeoutMs, env: headerEnv });
970
+ }
971
+ }
953
972
  if (result.code !== 0 && !opts.allowNonZero) {
954
973
  const combined = result.stderr + ' ' + result.stdout;
955
974
  // 统一分类(超时 / 网络 / 鉴权 / 5xx…):上层可据 kind / retryable 分流,不必解析 message
@@ -961,12 +980,19 @@ export class GitTransport implements SyncTransport {
961
980
  // 推送 + 鉴权失败:git 原文只说「认证失败」——换成能照做的说明(令牌失效 / 无写权限 / 远端禁用了密码推送)
962
981
  // issue #115:clone 的鉴权失败原文(remote: Write access to repository not granted / 403)读不出「缺哪种权限」,
963
982
  // 换成点名 Contents: Read and write 的说明 —— 否则用户只会以为令牌坏了(细粒度 PAT 常只勾了 Administration)
964
- const message = opts.requirePushToken === true && classified.kind === 'auth'
965
- ? this.msg('sync.git.pushAuthFailed', { err: this.mask(result.stderr, token) })
966
- : args[0] === 'clone' && classified.kind === 'auth'
967
- ? this.msg('sync.git.cloneAuthFailed', { err: this.mask(result.stderr, token) })
968
- : this.msg('sync.git.cmdFailed', { args: args.join(' '), code: String(result.code), err: this.mask(result.stderr, token) });
969
- throw new GitTransportError(message, classified);
983
+ // issue #120:helper 根本没能执行时,「请确认令牌有效」是错误指引(令牌可能是好的)——
984
+ // 这类失败必须点名平台原因,排在 push/clone 的鉴权文案之前。
985
+ const credentialBlocked = isCredentialExecFailure(combined);
986
+ const message = credentialBlocked
987
+ ? this.msg('sync.git.credentialHelperBlocked', { err: this.mask(result.stderr, token) })
988
+ : opts.requirePushToken === true && classified.kind === 'auth'
989
+ ? this.msg('sync.git.pushAuthFailed', { err: this.mask(result.stderr, token) })
990
+ : args[0] === 'clone' && classified.kind === 'auth'
991
+ ? this.msg('sync.git.cloneAuthFailed', { err: this.mask(result.stderr, token) })
992
+ : this.msg('sync.git.cmdFailed', { args: args.join(' '), code: String(result.code), err: this.mask(result.stderr, token) });
993
+ // 归类同样不能交给原文:`cannot exec ...: No such file or directory` 里的 ENOENT 会被判成
994
+ // notfound(「仓库不存在」),而真实原因是凭据通道不可用 → 钉成 auth(非瞬时、别重试)。
995
+ throw new GitTransportError(message, credentialBlocked ? { kind: 'auth', retryable: false } : classified);
970
996
  }
971
997
  return result;
972
998
  } catch (err) {