fix(update): verify debug Metro project identity

这个提交包含在:
XuqmGroup 2026-07-27 16:32:38 +08:00
父节点 3a80ad5dd5
当前提交 4a00fc39f0
共有 6 个文件被更改,包括 159 次插入11 次删除

查看文件

@ -484,3 +484,13 @@ pnpm --dir packages/update test
- 工作区内 18 个 XuqmGroup 子仓库的本地 `origin` 也已改为 SSH,审计结果不再存在 - 工作区内 18 个 XuqmGroup 子仓库的本地 `origin` 也已改为 SSH,审计结果不再存在
`https://user:token@host` 形式。旧访问令牌曾出现在命令审计输出,必须在 Gitea `https://user:token@host` 形式。旧访问令牌曾出现在命令审计输出,必须在 Gitea
轮换;本文不记录其值。 轮换;本文不记录其值。
### 2026-07-27 / Debug Metro 工程身份约束
- `withXuqmModuleConfig()` 新增仅供本机开发链路读取的
`/xuqm/metro-info` 工程标识;它不进入 Release bundle,也不承载业务或凭据信息。
- `xuqm-rn run android/ios` 在复用已占用端口前校验工程绝对路径。其他工程的
Metro 或不支持该协议的旧 Metro 会被明确拒绝,避免 React Native CLI 仅凭
“packager running” 静默加载错误工程或陈旧代码。
- 当前工程的标准 Metro 继续直接提供 startup/common/app/buz 源码与 Fast Refresh;
Debug 不执行插件构建、解压或安装。单元测试覆盖工程标识响应与 CLI 参数转发。

查看文件

@ -53,6 +53,11 @@ module.exports = withXuqmModuleConfig(getDefaultConfig(__dirname))
- `pnpm release:android -- --apk`:生成包含同一批插件的 Release APK。 - `pnpm release:android -- --apk`:生成包含同一批插件的 Release APK。
- `pnpm publish:android`上传插件产物;SDK npm 制品发布只能通过 Jenkins。 - `pnpm publish:android`上传插件产物;SDK npm 制品发布只能通过 Jenkins。
`withXuqmModuleConfig()` 会让开发服务器公开当前工程标识;`xuqm-rn run` 在复用
已有 Metro 前必须校验该标识。若端口属于其他工程或不支持标识校验的旧服务,命令
会明确失败并要求重启 Metro,禁止静默连接后运行错误 bundle。确认属于当前工程后
仍完全使用标准 Metro 与 Fast Refresh,不生成或读取 Debug 插件包。
发布使用的 `XUQM_API_TOKEN` 只能由 Jenkins Credentials 注入环境变量,不属于 发布使用的 `XUQM_API_TOKEN` 只能由 Jenkins Credentials 注入环境变量,不属于
`xuqm.modules.json``config.xuqmconfig`。CLI 不读取项目文件中的 Token,避免可用凭据 `xuqm.modules.json``config.xuqmconfig`。CLI 不读取项目文件中的 Token,避免可用凭据
进入源码、插件包或构建日志。 进入源码、插件包或构建日志。

查看文件

@ -80,10 +80,35 @@ function createModuleSerializer(environment = process.env) {
function withXuqmModuleConfig(metroConfig) { function withXuqmModuleConfig(metroConfig) {
const configured = withXuqmConfig(metroConfig) const configured = withXuqmConfig(metroConfig)
const moduleSerializer = createModuleSerializer() const moduleSerializer = createModuleSerializer()
if (!moduleSerializer) return configured
const configuredFilter = configured.serializer?.processModuleFilter const configuredFilter = configured.serializer?.processModuleFilter
return { const configuredEnhanceMiddleware = configured.server?.enhanceMiddleware
const result = {
...configured, ...configured,
server: {
...configured.server,
enhanceMiddleware(middleware, metroServer) {
const nextMiddleware = configuredEnhanceMiddleware
? configuredEnhanceMiddleware(middleware, metroServer)
: middleware
return (request, response, next) => {
if (request.url === '/xuqm/metro-info') {
response.setHeader('Content-Type', 'application/json; charset=utf-8')
response.end(
JSON.stringify({
projectRoot: path.resolve(process.cwd()),
protocolVersion: 1,
}),
)
return
}
nextMiddleware(request, response, next)
}
},
},
}
if (!moduleSerializer) return result
return {
...result,
serializer: { serializer: {
...configured.serializer, ...configured.serializer,
...moduleSerializer, ...moduleSerializer,

查看文件

@ -6,7 +6,7 @@ import path from 'node:path'
import test from 'node:test' import test from 'node:test'
const require = createRequire(import.meta.url) const require = createRequire(import.meta.url)
const { createModuleSerializer } = require('../metro') const { createModuleSerializer, withXuqmModuleConfig } = require('../metro')
function context(cacheFile, moduleId, moduleType, moduleIndex, reset = false) { function context(cacheFile, moduleId, moduleType, moduleIndex, reset = false) {
return { return {
@ -46,3 +46,32 @@ test('Metro module ranges share startup/common and isolate every app or buz', ()
rmSync(directory, { force: true, recursive: true }) rmSync(directory, { force: true, recursive: true })
} }
}) })
test('Metro exposes the current project identity for safe debug reuse', () => {
const configured = withXuqmModuleConfig({})
const middleware = configured.server.enhanceMiddleware((_request, _response, next) => next(), {})
let contentType = ''
let responseBody = ''
let delegated = false
middleware(
{ url: '/xuqm/metro-info' },
{
setHeader(name, value) {
if (name === 'Content-Type') contentType = value
},
end(value) {
responseBody = value
},
},
() => {
delegated = true
},
)
assert.equal(delegated, false)
assert.equal(contentType, 'application/json; charset=utf-8')
assert.deepEqual(JSON.parse(responseBody), {
projectRoot: path.resolve(process.cwd()),
protocolVersion: 1,
})
})

查看文件

@ -13,6 +13,7 @@ import {
writeFileSync, writeFileSync,
} from 'node:fs' } from 'node:fs'
import { createRequire } from 'node:module' import { createRequire } from 'node:module'
import http from 'node:http'
import path from 'node:path' import path from 'node:path'
import process from 'node:process' import process from 'node:process'
import { strToU8, zipSync } from 'fflate' import { strToU8, zipSync } from 'fflate'
@ -663,8 +664,75 @@ function startMetro(optionArgs) {
runReactNative(['start', ...optionArgs]) runReactNative(['start', ...optionArgs])
} }
function runApp(platform, optionArgs) { function optionValue(optionArgs, name) {
const index = optionArgs.indexOf(name)
return index >= 0 ? optionArgs[index + 1] : undefined
}
function readMetroIdentity(port) {
return new Promise(resolve => {
const request = http.get(
{
host: '127.0.0.1',
path: '/xuqm/metro-info',
port,
timeout: 800,
},
response => {
let body = ''
response.setEncoding('utf8')
response.on('data', chunk => {
body += chunk
})
response.on('end', () => {
if (response.statusCode !== 200) {
resolve({ kind: 'unmanaged' })
return
}
try {
const value = JSON.parse(body)
resolve(
typeof value?.projectRoot === 'string'
? { kind: 'managed', projectRoot: path.resolve(value.projectRoot) }
: { kind: 'unmanaged' },
)
} catch {
resolve({ kind: 'unmanaged' })
}
})
},
)
request.on('timeout', () => request.destroy())
request.on('error', error => {
resolve(
error?.code === 'ECONNREFUSED' || error?.code === 'ECONNRESET'
? { kind: 'absent' }
: { kind: 'unmanaged' },
)
})
})
}
async function assertMetroBelongsToCurrentProject(optionArgs) {
if (optionArgs.includes('--no-packager')) return
const parsedPort = Number.parseInt(optionValue(optionArgs, '--port') ?? '8081', 10)
const port = Number.isInteger(parsedPort) && parsedPort > 0 ? parsedPort : 8081
const identity = await readMetroIdentity(port)
if (identity.kind === 'absent') return
if (identity.kind === 'managed' && identity.projectRoot === path.resolve(root)) return
if (identity.kind === 'managed') {
fail(
`Metro port ${port} belongs to ${identity.projectRoot}; stop it or choose another --port before running this project`,
)
}
fail(
`Metro port ${port} is occupied by an unidentified or outdated server; restart it with \`xuqm-rn start --reset-cache\``,
)
}
async function runApp(platform, optionArgs) {
assertPlatform(platform) assertPlatform(platform)
await assertMetroBelongsToCurrentProject(optionArgs)
runReactNative([platform === 'android' ? 'run-android' : 'run-ios', ...optionArgs]) runReactNative([platform === 'android' ? 'run-android' : 'run-ios', ...optionArgs])
} }
@ -705,7 +773,7 @@ try {
startMetro(args) startMetro(args)
break break
case 'run': case 'run':
runApp(args.shift() ?? 'android', args) await runApp(args.shift() ?? 'android', args)
break break
case 'build': case 'build':
build(args.shift() ?? 'android', args) build(args.shift() ?? 'android', args)

查看文件

@ -552,15 +552,26 @@ test('start and run forward arguments through the host-local React Native CLI',
env: environment, env: environment,
stdio: 'pipe', stdio: 'pipe',
}) })
execFileSync(process.execPath, [cli, 'run', 'android', '--device', 'emulator-5554'], { execFileSync(
cwd: root, process.execPath,
env: environment, [cli, 'run', 'android', '--device', 'emulator-5554', '--port', '45999', '--no-packager'],
stdio: 'pipe', {
}) cwd: root,
env: environment,
stdio: 'pipe',
},
)
const calls = JSON.parse(readFileSync(path.join(root, 'calls.json'), 'utf8')) const calls = JSON.parse(readFileSync(path.join(root, 'calls.json'), 'utf8'))
assert.deepEqual(calls[0].args, ['start', '--reset-cache']) assert.deepEqual(calls[0].args, ['start', '--reset-cache'])
assert.deepEqual(calls[1].args, ['run-android', '--device', 'emulator-5554']) assert.deepEqual(calls[1].args, [
'run-android',
'--device',
'emulator-5554',
'--port',
'45999',
'--no-packager',
])
assert.equal(calls[0].forceColor, '0') assert.equal(calls[0].forceColor, '0')
assert.equal(calls[1].forceColor, '0') assert.equal(calls[1].forceColor, '0')
assert.equal(calls[0].noColor, null) assert.equal(calls[0].noColor, null)