fix(update): verify debug Metro project identity
这个提交包含在:
父节点
3a80ad5dd5
当前提交
4a00fc39f0
@ -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(
|
||||||
|
process.execPath,
|
||||||
|
[cli, 'run', 'android', '--device', 'emulator-5554', '--port', '45999', '--no-packager'],
|
||||||
|
{
|
||||||
cwd: root,
|
cwd: root,
|
||||||
env: environment,
|
env: environment,
|
||||||
stdio: 'pipe',
|
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)
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户