Compare commits

...

4 Commits

44 changed files with 2733 additions and 40 deletions

View File

@@ -57,6 +57,8 @@ files, err := oss.New(client, bucket, 32<<20)
业务需要临时链接时显式依赖额外接口 `storage.Presigner`。S3/OSS 实现 `PresignGet`;Local 用 `storage.NewSigner(files, downloadBaseURL, secret)` 提供 HTTP 下载 Handler。链接 TTL 为 1 秒到 1 小时,私钥/临时凭据可能让云链接提前失效。签发链接前必须完成业务授权;对象 key 及 bucket 本身不是租户权限。签名链接不要写日志。受限直传和云端分片现已提供可选接口,见 [上传与续传说明](storage/README.md)。生命周期、ACL、CDN 自定义域名和版本管理仍由平台/部署管理。
需要 inline 预览、显示文件名或指定响应 MIME 时,使用可选 `storage.ReadPresigner.PresignGetWithOptions`,返回 URL 和签名内的实际到期时间。Local、S3/R2、OSS 使用相同选项;Local 支持 HEAD 与范围读取,云端由原生存储服务处理。旧下载接口不变,见 [签名读取说明](storage/README.md#签名预览与下载)和[完整示例](examples/signedRead/README.md)。
R2 实现 S3 的子集,配置入口使用标准账户 endpoint;法域专属 endpoint 可显式配置。兼容性依据:[R2 S3 API](https://developers.cloudflare.com/r2/api/s3/api/)、[R2 签名链接](https://developers.cloudflare.com/r2/api/s3/presigned-urls/)。OSS 使用其[官方 Go SDK v2](https://www.alibabacloud.com/help/en/oss/developer-reference/manual-for-go-sdk-v2/),没有假定全部 S3 行为与 OSS 等价。
验证区分:Local 契约、OSS SDK HTTP fixture、S3 SDK fixture 和 R2 签名参数属于本地测试;Linux runner 再跑真实 MinIO 上传/读取/删除与签名拒绝。**MinIO 通过不能代替 OSS/R2 实网验证。**迁移存储平台还需要复制已有对象和调整部署权限,不只是切配置。
@@ -121,6 +123,20 @@ log:
`telemetry.New` 接受 OTel `sdktrace.SpanExporter` 和 `sdkmetric.Reader`;可注入官方 OTLP exporter、PeriodicReader 或测试用 exporter,基础包不强制选择 collector 协议。`app.Use(runtime.Middleware())` 采集入站,`telemetry.Transport` 采集出站并传播 W3C trace context;Redis 使用 `client.AddHook(telemetry.RedisHook{Runtime: runtime})`,SQL 使用 `telemetry.SQL{DB: db, Runtime: runtime}`。业务操作/事务可用 `runtime.Do(ctx, "fixedOperationName", fn)` 包裹。
上游网关如果使用自定义 Header,可配置通用兼容传播器,不在业务 Handler 里解析 Header:
```go
propagator, err := telemetry.NewHeaderPropagator(telemetry.HeaderPropagationConfig{
TraceIDHeader: "X-Trace-Id", SpanIDHeader: "X-Span-Id", RequestIDHeader: "X-Request-Id",
})
if err != nil { return err }
runtime, err := telemetry.New(telemetry.Config{
Service: "billing", SampleRatio: 1, Propagator: propagator,
})
```
`HeaderPropagator` 同时支持 W3C `traceparent`;两者同时存在时优先 W3C。有效的兼容 Header 会成为远程父 Span,当前服务仍生成自己的 Span ID,`logx.TraceID` / `RequestID`、访问日志和审计会沿用上下文,出站 `telemetry.Transport` 会同时传播 W3C 和配置的兼容 Header。自定义 Header 不携带采样标志,适配器默认把有效成对 ID 当作已采样;需要保留上游采样决定时使用 W3C。只有受信网关入口才应启用该适配器,Trace/Span ID 不是认证、租户或幂等凭据。完整离线示例见 [telemetryHeaders](examples/telemetryHeaders/README.md)。
创建者负责在停止请求/Worker 后调用 `runtime.Shutdown(ctx)` 刷新并关闭 exporter/reader。指标按注册路由模板和固定方法分类;自动采集不记录 URL query、SQL 正文、Redis key、token 或业务载荷。业务自定义 span 名称同样应固定,避免用用户 ID 或对象 key。
## 分布式限流与幂等

View File

@@ -1,3 +1,17 @@
# v0.30.0-preview
- 新增通用 `telemetry.HeaderPropagator` 与 `HeaderPropagationConfig`,配置化接入 Trace ID、Span ID、Request ID Header;不绑定任何网关名称。
- 入站优先使用标准 W3C `traceparent`;缺少有效 W3C 父链路时,合法的兼容 Header 对成为远程父 Span。日志、审计、访问日志沿用 request/trace 上下文。
- 出站 `telemetry.Transport` 同时传播 W3C 和配置的兼容 Header;旧配置不变,默认仍只使用 W3C。
- 补充自定义 Header、W3C 优先级、非法 ID、父子 Span 和通用离线示例回归;不引入业务类型、网关名称或第三方依赖。
# v0.29.0-preview
- 新增可选 `storage.ReadPresigner` / `ReadOptions` / `ReadLink`,签名读取可选择 inline/attachment、显示文件名、MIME 类型,返回签名内的实际到期时间。原 Store/Presigner 和旧下载链接兼容,TTL 上限仍为一小时。
- Local、S3/R2、OSS 均实现;全部选项参与本地 HMAC 或云端原生 SDK 签名,标准编码中文文件名并拒绝非法响应头。
- 本地读取支持 GET/HEAD、单段与多段 Range、206/416、条件请求,按文件内容真实长度处理,流式读取不暴露本地元数据。顺序读取的第三方 Store 保持完整 200 回退。
- 补充通用 PDF 运行示例、篡改与到期回归、OSS V1/V4 签名校验、真实隔离 MinIO 验证和远程安装检查;不引入业务模型或第三方依赖。
# v0.28.2-preview
- 修复响应 DTO 的自引用/相互引用生成,支持 slice、pointer、map;OpenAPI 使用命名组件与循环引用,保留约束、描述、示例及 scalar 输出 schema。

View File

@@ -1,4 +1,4 @@
# 通用业务开发指南(v0.28.2-preview)
# 通用业务开发指南(v0.30.0-preview)
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。
@@ -225,7 +225,7 @@ go-omni workspace check --file omni.workspace.json
### 混合 API 与通用工程
v0.28.2-preview 起,workspace 支持在同一份清单中管理 API、通用工程,也支持仅包含通用工程的工作区:
v0.29.0-preview 起,workspace 支持在同一份清单中管理 API、通用工程,也支持仅包含通用工程的工作区:
```json
{

View File

@@ -5,7 +5,7 @@
## 创建与运行
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.2-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.30.0-preview
go-omni project new demo --module example.com/demo
cd demo
go mod tidy
@@ -78,4 +78,4 @@ go test ./...
check 检查模板漂移和必要文件,不检查业务正确性,也不能替代 Go 编译与测试。新的模板版本会重新生成受管文件;不自动合并项目所有文件或更新 go.mod。未知的新清单版本会要求使用兼容 CLI。
原有 `new` / `api new` 及 API 工程行为保留。通用工程使用自己的清单和命令,不能交给 `api generate` 或 `add` 转换;新增业务包直接写普通 Go。从 v0.28.2-preview 起,`workspace generate/check` 可以同时处理 API 与通用工程。清单可填写 `kind: api | project`,省略时按生成清单识别;format 对通用工程显示 SKIP。见 [完整混合示例](examples/mixedWorkspace/README.md)。各 module 仍独立,workspace 命令不维护 go.work 或模块依赖。
原有 `new` / `api new` 及 API 工程行为保留。通用工程使用自己的清单和命令,不能交给 `api generate` 或 `add` 转换;新增业务包直接写普通 Go。从 v0.29.0-preview 起,`workspace generate/check` 可以同时处理 API 与通用工程。清单可填写 `kind: api | project`,省略时按生成清单识别;format 对通用工程显示 SKIP。见 [完整混合示例](examples/mixedWorkspace/README.md)。各 module 仍独立,workspace 命令不维护 go.work 或模块依赖。

View File

@@ -2,6 +2,10 @@
GoOmni 是面向多个项目复用的 Go 基础框架。通用工程使用 `project new` 生成配置、日志、依赖装配、生命周期和普通 Go 运行入口;需要 HTTP 接口时可选择 `.api` 工程生成,也可以在已有项目中按需引入独立模块。运行方式、业务类型和外部依赖由项目决定。
**v0.29 改动**:通用签名读取新增 inline/attachment、中文显示文件名、可选 Content-Type 与实际到期时间;Local、S3/R2、OSS 使用同一接口,原下载接口兼容。本地支持 HEAD、Range、206/416,所有响应选项参与签名。见 [存储说明](storage/README.md#签名预览与下载)及[可运行 PDF 示例](examples/signedRead/README.md)。
**v0.30 改动**:telemetry 新增配置化 `HeaderPropagator`,兼容网关传入的 Trace/Span/Request Header,同时保留 W3C `traceparent`;入站父链路、日志关联和出站传播统一处理。框架不绑定任何网关名称。见[可运行示例](examples/telemetryHeaders/README.md)和[观测说明](ADVANCED.md#熔断与观测)。
**v0.28.2 改动**:API 响应 DTO 支持 `Children []Node`、`Next *Node`、递归 map 和相互引用,生成强类型 Go 字段及有限的 OpenAPI `$ref` 图。字段约束、示例校验、生成与漂移检查继续有效;请求递归和非法值递归仍会报错。见 [可运行示例](examples/recursiveResponse/README.md)与[接入说明](DEVELOPMENT.md#递归响应-dto)。
**v0.28.1 改动**:`workspace generate/check` 支持 API 与通用工程混合处理,可选 `kind: api | project`,省略时按生成清单识别。`format` 对通用工程显示跳过;检查只读,生成前统一预检。见 [混合工作区示例](examples/mixedWorkspace/README.md)。
@@ -10,9 +14,9 @@ GoOmni 是面向多个项目复用的 Go 基础框架。通用工程使用 `proj
**v0.27 改动**:统一三种内置服务实现的默认/路由/硬请求体限额,配置冲突在启动前报错;停机以处理函数实际退出为资源清理前提,超时仍有请求时保留共享依赖。见 [HTTP 策略](transport/README.md#统一请求体限额)、[停机规则](lifecycle/README.md#http-请求与依赖的关闭屏障)和[可运行示例](examples/httpSafety/README.md)。
**v0.26 改动**:通用 [i18n](i18n/README.md),提供独立词典、语言匹配、回退、命名参数/条件/复数、可选 HTTP 与错误适配、检查/导出 CLI 及前端运行时。文案由项目维护;[完整示例](examples/i18n/README.md)可直接运行。CLI 与框架使用同版 v0.28.2-preview。同时修复 Go 1.22 下请求体限额错误的识别。
**v0.26 改动**:通用 [i18n](i18n/README.md),提供独立词典、语言匹配、回退、命名参数/条件/复数、可选 HTTP 与错误适配、检查/导出 CLI 及前端运行时。文案由项目维护;[完整示例](examples/i18n/README.md)可直接运行。CLI 与框架使用同版 v0.30.0-preview。同时修复 Go 1.22 下请求体限额错误的识别。
**从这份 README 开始接入。** 当前版本 **`v0.28.2-preview`**,通用工程采用根目录入口和 `config / svc / run`;API 工程使用 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
**从这份 README 开始接入。** 当前版本 **`v0.30.0-preview`**,通用工程采用根目录入口和 `config / svc / run`;API 工程使用 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
**v0.25 改动**:`database.InTx` 返回可识别的事务阶段与结果,保留业务/驱动及回滚错误链;提交报错保守标记为结果不确定,不等同于已回滚。[使用规则](database/README.md#事务阶段与结果)、[可运行故障示例](examples/transactions/README.md)。
@@ -68,8 +72,8 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可
| 请求追踪、访问日志、恢复、限流、幂等、超时 | `app.Use(...)` 按需组合 middleware;跨副本选择 Redis 实现 | [模块说明与限制](MODULES.md#请求追踪与基础指标)、[分布式实现](ADVANCED.md#分布式限流与幂等) |
| 本地后台任务与调度 | `task.New`、Submit、Every;有界队列、排空关闭 | [task 示例与生命周期](MODULES.md#后台执行task) |
| 持久队列 | 业务使用 Publisher / Consumer;Redis Streams 提供至少一次交付 | [队列初始化/消费示例](ADVANCED.md#可靠队列) |
| 文件存储、直传、云端分片 | Store 的 Put/Get/Delete;Local、S3/R2、OSS 按配置装配;扩展接口提供分片续传 | [存储接口与完整例子](storage/README.md)、[本地存储服务](examples/platform/README.md) |
| 日志关联、指标与链路 | `metrics.New().Middleware()`;telemetry 注入 OTel exporter/reader | [基础指标](MODULES.md#请求追踪与基础指标)、[OTel 用法](ADVANCED.md#熔断与观测)、[完整接入](examples/platform/main.go) |
| 文件存储、签名预览/下载、直传与云端分片 | Store 的 Put/Get/Delete;ReadPresigner 签名响应选项;Local、S3/R2、OSS 按配置装配 | [存储接口与完整例子](storage/README.md)、[本地存储服务](examples/platform/README.md) |
| 日志关联、指标与链路 | `metrics.New().Middleware()`;telemetry 注入 OTel exporter/reader;`HeaderPropagator` 兼容自定义网关 Header | [基础指标](MODULES.md#请求追踪与基础指标)、[OTel 用法](ADVANCED.md#熔断与观测)、[Header 示例](examples/telemetryHeaders/README.md) |
| 审计与分页 | `audit.New` 注入 Sink/白名单;`pagination.Parse` / `NewResult` | [解释与调用例子](ADVANCED.md#迁移审计与分页) |
| 可选结构转换与深拷贝 | `copyx.Copy` / `Clone` / `New`;`Check[S,T]` 预检类型,Go converter 处理差异 | [下方示例](#可选结构转换与深拷贝)、[完整 copyx 指南](COPY.md)、[可运行代码](examples/copying) |
| 自定义适配器验收 | `transporttest.Run`、`storagetest.Run` / `RunUploads` 验证扩展契约 | [网络契约测试入口](transport/transporttest/router.go)、[存储契约测试入口](storage/storagetest/contract.go) |
@@ -77,7 +81,7 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可
## 通用工程快速开始
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.2-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.30.0-preview
go-omni project new demo --module example.com/demo
cd demo
go mod tidy
@@ -182,7 +186,7 @@ module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可
```sh
export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk"
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.2-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.30.0-preview
export PATH="$(go env GOPATH)/bin:$PATH"
go-omni --help
```
@@ -339,7 +343,7 @@ go-omni new --api catalog.api \
在业务项目的 **go.mod 所在目录**执行:
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.28.2-preview
go get git.yuntuo.hk/k2safe/go-omni@v0.30.0-preview
```
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
@@ -670,7 +674,7 @@ go test ./...
`main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、`omni.gen.json`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。
CLI 与业务 module 固定使用 `v0.28.2-preview`。v0.21 及更早项目先按 [配置迁移](CONFIG.md#v022-迁移旧模式已移除) 整理旧标签、文件与 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
CLI 与业务 module 固定使用 `v0.30.0-preview`。v0.21 及更早项目先按 [配置迁移](CONFIG.md#v022-迁移旧模式已移除) 整理旧标签、文件与 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
## 按需深入

View File

@@ -1,3 +1,46 @@
# v0.30.0-preview
新增通用 `telemetry.HeaderPropagator`,让服务在不修改业务 Handler 的情况下接入使用自定义 Trace/Span/Request Header 的上游网关。配置 Header 名称后,入站建立远程父 Span,`logx.TraceID` / `RequestID`、访问日志和审计沿用上下文;出站 `telemetry.Transport` 同时注入 W3C 和兼容 Header。
W3C `traceparent` 始终优先于兼容 Header。兼容 Header 只接受合法的 32 位 Trace ID 和 16 位 Span ID,当前服务生成自己的 Span ID;Request ID 单独传播,不参与父 Span。旧项目不配置新传播器时,默认行为不变。兼容 Header 不携带采样标志,有效成对值按已采样处理;需要传递采样决定时使用 W3C。
```go
propagator, err := telemetry.NewHeaderPropagator(telemetry.HeaderPropagationConfig{
TraceIDHeader: "X-Trace-Id", SpanIDHeader: "X-Span-Id", RequestIDHeader: "X-Request-Id",
})
if err != nil { return err }
runtime, err := telemetry.New(telemetry.Config{
Service: "billing", SampleRatio: 1, Propagator: propagator,
})
```
只在受信网关入口启用兼容传播;Trace/Span ID 不是认证、租户或幂等凭据。完整 [接入说明](ADVANCED.md#熔断与观测)、[离线示例](examples/telemetryHeaders/README.md)。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.30.0-preview
go test -race ./telemetry ./examples/telemetryHeaders
```
---
# v0.29.0-preview
新增通用签名读取选项:预览或下载、显示文件名和 MIME 类型,由调用方选择。`PresignGetWithOptions` 返回 URL 与签名内的到期时间;原 `PresignGet` 默认下载、第三方原有接口和旧本地链接继续兼容。有效期仍是 1 秒到 1 小时,不增加撤销或业务分享机制。
Local、S3/R2、OSS 使用同一接口,所有选项参与签名。中文文件名采用标准编码,拒绝 CR/LF 等非法头内容。本地支持 HEAD、单段/多段 Range、206/416 与条件请求,按真实内容范围流式输出;过期与篡改在读取文件之前拒绝。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.29.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.29.0-preview
go test ./...
```
[接口与接入边界](storage/README.md#签名预览与下载)、[完整示例](examples/signedRead/README.md)、[验收记录](reports/signedReadAcceptance.md)。生成模板最低运行时版本不变;使用新读取接口需要升级框架依赖。OSS 默认域名可能强制下载,应在 SDK 签名前配置所需域名;OSS/R2 云账号尚未实测,部署方须按实际桶、域名和浏览器验收。
57 个测试包的全量 race 与 Go 1.22 集成回归、vet/build、隔离 MinIO 验证通过。远程空缓存综合 107 项检查、11 组生成消费者、通用工程 30 项与混合工作区 16 项通过,均无本地 replace。标签固定在 `f0c62152d72b1b50672c29f5191ad88f8be3eee4`,见 [远程报告](reports/signedReadRemote.json)。
---
# v0.28.2-preview
修复 `.api` 响应 DTO 自引用与相互引用的生成限制。`Children []Node`、`Next *Node`、`map[string]Node` 保留强类型,响应文档使用有限的命名组件与 `$ref`;可空引用、字段描述、示例、约束和 scalar 输出类型保持准确。框架不引入业务模型。
@@ -13,6 +56,8 @@ go test ./...
[递归响应说明](DEVELOPMENT.md#递归响应-dto)、[可运行示例](examples/recursiveResponse/README.md)。56 个测试包全量 race 与 Go 1.22 集成回归、vet/build、独立 OpenAPI 校验均通过,见[验收记录](reports/recursiveResponseAcceptance.md)。
远程空缓存验收完成:响应与兼容 101 项检查、11 组生成消费者,通用工程 30 项、混合工作区 16 项检查全部通过,均无本地 replace。标签固定在 `0c5b571b31b68d25cdb3e8640970e2a53ddcbea6`,详见[远程报告](reports/recursiveResponseRemote.json)。
---
# v0.28.1-preview

View File

@@ -20,7 +20,7 @@ API 新项目采用根目录 `main.go` 与 `internal/config、svc、logic、hand
```sh
# 仓库读取权限与 PATH 配置见根目录 README
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.2-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.30.0-preview
go-omni new --module example.com/hello ./hello
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
@@ -57,14 +57,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节
## 版本与迁移
CLI 与框架依赖固定使用 v0.28.2-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
CLI 与框架依赖固定使用 v0.30.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
```sh
# 在 go-omni 源码仓库中执行
go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello
```
当前模板与依赖以 v0.28.2-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
当前模板与依赖以 v0.30.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
## 数据库迁移命令

View File

@@ -23,7 +23,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
// Runtime-only releases need not raise the managed template compatibility floor.
const minimumVersion = "v0.22.0-preview"
const currentVersion = "v0.28.2-preview"
const currentVersion = "v0.30.0-preview"
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
type Options struct {

View File

@@ -25,7 +25,7 @@ import (
)
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
const currentVersion = "v0.28.2-preview"
const currentVersion = "v0.30.0-preview"
const minimumVersion = "v0.28.0-preview"
const manifestPath = ".goOmni/projectManifest.json"
const lockPath = ".goOmni/project.lock"

View File

@@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
// Source builds can carry an unpublished VCS version (including +dirty).
// Keep this fallback in sync with the next release; installed CLIs use their
// downloaded module version, which also has a module checksum.
const defaultFrameworkVersion = "v0.28.2-preview"
const defaultFrameworkVersion = "v0.30.0-preview"
// This target is not a claim that the release already exists. Source users
// select a local framework checkout until the module templates are published.

View File

@@ -1,11 +1,11 @@
# API 与通用工程混合工作区
本例只定义生成工程的类型与目录,不包含业务模型、外部服务或特定调度方式。使用 v0.28.2-preview 或更新版本 CLI。
本例只定义生成工程的类型与目录,不包含业务模型、外部服务或特定调度方式。使用 v0.30.0-preview 或更新版本 CLI。
下面从一个空目录创建两个独立 Go module;API 工程来自 API 模板,通用工程默认不监听端口。
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.2-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.30.0-preview
mkdir mixed-demo
cd mixed-demo
go-omni api new apps/interface --module example.com/interface

View File

@@ -5,7 +5,7 @@
在 go-omni 仓库根目录执行(新建独立临时目录):
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.2-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.30.0-preview
example_dir="$(mktemp -d)/recursive"
go-omni api generate --api examples/recursiveResponse/service.api --dir "$example_dir" --module example.com/recursive
# 以下文件仅是本次新生成示例的占位实现,替换为示例自身维护的逻辑。

View File

@@ -0,0 +1,15 @@
# 通用签名读取
```sh
go run ./examples/signedRead
# 或执行真实 HTTP 检查并退出
go run ./examples/signedRead -check
```
示例自动创建临时私有存储、一页 PDF 和随机签名密钥,监听随机 loopback 端口。打开终端显示的首页,可预览或下载带中文文件名的 PDF;两条链接从签发起 30 分钟有效。首页显示签名内的实际到期时间,不会通过刷新延长链接;重启会生成新的文件和密钥。Ctrl+C 清理临时目录。
示例使用 `storage.ReadPresigner` 对应的 `PresignGetWithOptions` 能力,本地 `Signer` 与 S3/R2、OSS 适配器采用同一组 `ReadOptions`。省略 Disposition 默认 attachment,inline 请求浏览器内联展示;实际展示仍由浏览器设置和文件格式支持决定。原 `PresignGet` 接口与既有下载链接保持兼容。
本地支持 GET、HEAD、Range、206/416,响应范围只包含文件内容。云端由原生存储地址响应,GET 签名不通用于 HEAD;OSS 默认域名可能强制下载,应在 SDK 签名前配置自己的域名。详见 [存储接入说明](../../storage/README.md#签名预览与下载)。
这只是回环地址上的固定测试文件演示。实际项目先检查权限,再签发指定对象的链接;配置公开签名入口,不能从请求 Host 或转发头猜测。文件路径、权限、审计和页面状态由项目维护。不要记录 URL 查询参数,不公开存储目录。链接到期仅阻止后续读取,不收回已获得的内容;重新签发不会撤销旧链接。

183
examples/signedRead/main.go Normal file
View File

@@ -0,0 +1,183 @@
// signedRead is an offline, loopback-only preview/download demonstration.
package main
import (
"bytes"
"context"
"crypto/rand"
"errors"
"flag"
"fmt"
"html/template"
"io"
"mime"
"net"
"net/http"
"os"
"os/signal"
"strconv"
"time"
"git.yuntuo.hk/k2safe/go-omni/storage"
"git.yuntuo.hk/k2safe/go-omni/storage/local"
)
func main() {
check := flag.Bool("check", false, "run HTTP checks and exit")
flag.Parse()
if err := run(*check); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(check bool) error {
root, err := os.MkdirTemp("", "go-omni-signed-read-")
if err != nil {
return err
}
defer os.RemoveAll(root)
files, err := local.New(root, 1<<20)
if err != nil {
return err
}
content := samplePDF()
if _, err = files.Put(context.Background(), "sample.pdf", bytes.NewReader(content), int64(len(content)), "application/pdf"); err != nil {
return err
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return err
}
defer listener.Close()
baseURL := "http://" + listener.Addr().String()
secret := make([]byte, 32)
if _, err = rand.Read(secret); err != nil {
return err
}
signer, err := storage.NewSigner(files, baseURL+"/file", secret)
if err != nil {
return err
}
// Production applications authorize the caller before this operation. The
// sample intentionally exposes only a generated fixture on loopback.
preview, err := signer.PresignGetWithOptions(context.Background(), "sample.pdf", 30*time.Minute, storage.ReadOptions{
Disposition: storage.DispositionInline, FileName: "示例报告.pdf", ContentType: "application/pdf",
})
if err != nil {
return err
}
download, err := signer.PresignGetWithOptions(context.Background(), "sample.pdf", 30*time.Minute, storage.ReadOptions{FileName: "示例报告.pdf"})
if err != nil {
return err
}
page := template.Must(template.New("page").Parse(`<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>Signed read example</title>
<body><h1>文件预览与下载</h1><p><a href="{{.Preview.URL}}">打开示例 PDF</a> · <a href="{{.Download.URL}}">下载示例 PDF</a></p>
<p>链接到期时间:{{.Preview.ExpiresAt}}</p><p>只读演示:无数据库、业务身份或公开磁盘目录。</p></body></html>`))
mux := http.NewServeMux()
mux.Handle("/file", signer)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" || r.Method != http.MethodGet {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "private, no-store")
w.Header().Set("Referrer-Policy", "no-referrer")
_ = page.Execute(w, struct{ Preview, Download storage.ReadLink }{preview, download})
})
server := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 30 * time.Second}
done := make(chan error, 1)
go func() { done <- server.Serve(listener) }()
defer server.Close()
if check {
if err := checkHTTP(preview, download, content); err != nil {
return err
}
fmt.Println("signed read: PDF preview, Chinese filename, download, HEAD and Range passed")
return nil
}
// Never print signed URLs or enable request logging containing query strings.
fmt.Println("Open " + baseURL + " (Ctrl+C to stop)")
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
select {
case err := <-done:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
case <-ctx.Done():
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return server.Shutdown(shutdown)
}
}
func checkHTTP(preview, download storage.ReadLink, content []byte) error {
client := &http.Client{Timeout: 5 * time.Second}
for _, tc := range []struct {
link storage.ReadLink
method, span, disposition string
status int
body []byte
}{
{preview, "GET", "", "inline", 200, content},
{preview, "HEAD", "", "inline", 200, nil},
{preview, "GET", "bytes=0-7", "inline", 206, content[:8]},
{download, "GET", "", "attachment", 200, content},
} {
req, err := http.NewRequest(tc.method, tc.link.URL, nil)
if err != nil {
return errors.New("invalid example link")
}
if tc.span != "" {
req.Header.Set("Range", tc.span)
}
response, err := client.Do(req)
if err != nil {
return errors.New("example request failed")
}
body, err := io.ReadAll(response.Body)
response.Body.Close()
kind, params, headerErr := mime.ParseMediaType(response.Header.Get("Content-Disposition"))
if err != nil || headerErr != nil || response.StatusCode != tc.status || !bytes.Equal(body, tc.body) || kind != tc.disposition || params["filename"] != "示例报告.pdf" || response.Header.Get("Content-Type") != "application/pdf" {
return errors.New("example response did not match signed options")
}
size := len(tc.body)
if tc.method == "HEAD" {
size = len(content)
}
if response.Header.Get("Content-Length") != strconv.Itoa(size) || response.Header.Get("Cache-Control") != "private, no-store" {
return errors.New("example size or cache policy mismatch")
}
}
return nil
}
// A complete, single-page PDF with a real cross-reference table. No generator
// dependency or business document is needed for this example.
func samplePDF() []byte {
stream := "BT /F1 24 Tf 72 720 Td (go-omni signed read) Tj ET\n"
objects := []string{
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
fmt.Sprintf("<< /Length %d >>\nstream\n%sendstream", len(stream), stream),
}
var pdf bytes.Buffer
pdf.WriteString("%PDF-1.4\n")
offsets := []int{0}
for i, object := range objects {
offsets = append(offsets, pdf.Len())
fmt.Fprintf(&pdf, "%d 0 obj\n%s\nendobj\n", i+1, object)
}
xref := pdf.Len()
fmt.Fprintf(&pdf, "xref\n0 %d\n0000000000 65535 f \n", len(offsets))
for _, offset := range offsets[1:] {
fmt.Fprintf(&pdf, "%010d 00000 n \n", offset)
}
fmt.Fprintf(&pdf, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(offsets), xref)
return pdf.Bytes()
}

View File

@@ -0,0 +1,9 @@
package main
import "testing"
func TestSignedReadHTTP(t *testing.T) {
if err := run(true); err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,11 @@
# 配置化网关 Trace Header
```sh
go run ./examples/telemetryHeaders
```
这个离线示例使用标准库 `httptest`,不连接网关、数据库或外部 Collector。它把 `X-Trace-Id`、`X-Span-Id` 和 `X-Request-Id` 配置给通用 `telemetry.HeaderPropagator`,再通过 `app.Use(runtime.Middleware())` 进入服务。业务 Handler 只读取 `logx.TraceID` / `logx.RequestID`,不解析 Header。
`HeaderPropagator` 同时支持标准 W3C `traceparent`;两种格式同时存在时优先 W3C。出站请求使用 `telemetry.Transport` 时会注入 W3C 和配置的兼容 Header。旧项目不配置它时,go-omni 仍只使用默认 W3C 传播。
示例中的 ID 是固定测试值。生产环境只有在网关或受信代理已经校验这些 Header 时才启用兼容传播;Trace ID/Span ID 不是认证凭据,也不能用于授权、幂等或租户隔离。自定义 Header 不携带采样标志,适配器会把有效的成对 ID 视为已采样;需要保留上游采样决定时应使用 `traceparent`。

View File

@@ -0,0 +1,62 @@
// telemetryHeaders demonstrates a provider-neutral compatibility boundary for
// gateways that carry trace IDs in configurable HTTP headers.
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/telemetry"
)
func main() {
if err := run(); err != nil {
panic(err)
}
}
func run() error {
propagator, err := telemetry.NewHeaderPropagator(telemetry.HeaderPropagationConfig{
TraceIDHeader: "X-Trace-Id", SpanIDHeader: "X-Span-Id", RequestIDHeader: "X-Request-Id",
})
if err != nil {
return err
}
runtime, err := telemetry.New(telemetry.Config{Service: "header-example", SampleRatio: 1, Propagator: propagator})
if err != nil {
return err
}
defer runtime.Close()
app := omni.New(omni.WithLogger(false), omni.WithRequestIDHeader("X-Request-Id"))
defer app.Close()
app.Use(runtime.Middleware())
app.GET("/", func(c *omni.Context) error {
fmt.Printf("traceId=%s requestId=%s\n", logx.TraceID(c.Context()), logx.RequestID(c.Context()))
return c.OK(map[string]string{"traceId": logx.TraceID(c.Context()), "requestId": logx.RequestID(c.Context())})
})
server := httptest.NewServer(app)
defer server.Close()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil)
if err != nil {
return err
}
req.Header.Set("X-Trace-Id", "11111111111111111111111111111111")
req.Header.Set("X-Span-Id", "2222222222222222")
req.Header.Set("X-Request-Id", "example-request")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
fmt.Printf("status=%d response=%s", resp.StatusCode, body)
return nil
}

View File

@@ -0,0 +1,9 @@
package main
import "testing"
func TestTelemetryHeadersExample(t *testing.T) {
if err := run(); err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,24 @@
# Header 传播适配验收
## 范围
本次为 go-omni 增加通用 `telemetry.HeaderPropagator`。调用方配置 Trace ID、Span ID 和可选 Request ID Header 后,框架负责入站父链路、日志关联、访问日志和出站传播;框架不识别任何网关名称,也不解析业务 Header。
标准 W3C `traceparent` 与兼容 Header 同时出现时优先 W3C。兼容 Header 必须是合法的 32 位 Trace ID 和 16 位 Span ID;旧 Header 没有采样位,合法成对值按已采样处理。Request ID 只用于关联,不参与 Span 父子关系。兼容传播只应放在已校验 Header 的受信入口。
## 验收结果
- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -count=1 ./...`:通过,包含生成器、生命周期、存储、i18n、telemetry 和离线示例。
- `go test -race ./telemetry ./examples/telemetryHeaders`:通过。
- `GO_OMNI_SCAFFOLD_INTEGRATION=1 GOTOOLCHAIN=go1.22.12 go test -count=1 ./...`:通过。
- `go vet ./...`、`go build ./...`、`git diff --check`:通过。
- `go run ./examples/telemetryHeaders`:通过,示例打印固定 Trace ID、Request ID 和 JSON 响应。
完整竞态套件在高并发生成器生命周期用例中出现了已有的时序超时(`TestGeneratedMainLifecycle/drainBeforeClose`、`TestGeneratedBackgroundPhases/nethttp`);单独重跑受影响的 `TestManagedGeneratedProject` 通过,功能定向竞态测试通过,未发现本次 Header 传播相关竞态。
## 代码边界
- 默认 `telemetry.Config` 仍使用 W3C 传播,未配置新适配器的项目行为不变。
- `telemetry.Transport` 在兼容模式下同时注入 W3C 与配置 Header,调用方请求对象不会被修改。
- 无效 ID、重复 Header 名称以及试图覆盖 `traceparent` / `tracestate` 的配置会被拒绝或忽略。
- 不增加认证、租户、幂等、业务重试或网关依赖。

View File

@@ -23,4 +23,10 @@
## 发布验收
远程验收使用 `scripts/verifyManagedRelease.py`;新增递归消费者与其他消费者一起用空模块缓存安装固定版本 CLI,并核对 module checksum 和无本地 replace。通用工程与混合工作区分别运行其原有发布验收脚本。结果在发布后记录。
三个独立空模块缓存安装固定远程 `v0.28.2-preview`,均无本地 replace,全部通过:
- [响应与兼容报告](recursiveResponseRemote.json):101 项检查、11 组生成消费者,160.57 秒。包含强类型递归 HTTP、完整引用图、再生成和漂移检查,以及原有生命周期、上传、配置、错误和 i18n 回归。
- [通用工程报告](recursiveResponseProgramRemote.json):30 项检查,73.66 秒。
- [混合工作区报告](recursiveResponseWorkspaceRemote.json):16 项检查,66.19 秒。
三次下载的框架 checksum 一致:`h1:QjHXXV184SO45GCXLyejeVDdCzLa5EtRu2ngyYpLzYI=`。代码与固定标签指向 `0c5b571b31b68d25cdb3e8640970e2a53ddcbea6`;远程验收记录另行提交,不移动标签。

View File

@@ -0,0 +1,74 @@
{
"passed": true,
"frameworkVersion": "v0.28.2-preview",
"frameworkSum": "h1:QjHXXV184SO45GCXLyejeVDdCzLa5EtRu2ngyYpLzYI=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 73.66,
"tests": [
"TestConflictRollsBackEarlierWrites",
"TestUnsafePaths",
"TestGeneratedProgramConsumer",
"TestOwnershipAndDrift",
"TestRecordedTemplateUpgrade",
"TestProjectGuards/module",
"TestProjectGuards/oldFramework",
"TestProjectGuards/manifest",
"TestProjectGuards/lock",
"TestProjectGuards/otherGenerator",
"TestProjectGuards/symlink",
"TestProjectGuards"
],
"checks": [
"remoteCLIInstall",
"installedVersionDefault",
"generatedConsumerBuildRaceVet",
"defaultNoAPIFiles",
"defaultNoNetworkListener",
"finiteProgram",
"SIGINT",
"SIGTERM",
"initializationFailureAndPanic",
"runErrorPanicGoexit",
"fatalComponentFailure",
"nonzeroProcessFailureAndTimeoutExits",
"partialStartupDraining",
"acknowledgementBeforeResourceClose",
"workContextSurvivesGracefulStop",
"cancellationGrace",
"unresponsiveRunAndBackgroundWork",
"retainedResourcesAndLogger",
"explicitConfigurationOnly",
"checkConfigWithoutDependencies",
"handwrittenFilesPreserved",
"generatedFileConflict",
"missingGeneratedFileRestored",
"missingHandwrittenFileNotRecreated",
"readOnlyCheckAndDryRun",
"templateDriftUpgrade",
"manifestIdentityAndVersionGuards",
"symlinkAndLockProtection",
"fileTransactionRollback",
"publishedExampleFiniteRun"
],
"sourceHashes": {
"cmd/go-omni/internal/genfs/files.go": "97e338c74ddb3145eb0ec942b69d5c21c83dfb2f9f2165376fc83366ae1e93af",
"cmd/go-omni/internal/genfs/files_test.go": "a659b31ddf195c80d48cfc00302a3f4c6d5a1ed68b58d241da03a44ad54fe34a",
"cmd/go-omni/project.go": "07e362664053e59dbb8f8f46d4c7be9d66afb93a7142a810109b66cd296ae8a6",
"cmd/go-omni/projectgen/consumer_test.go": "312442d56599c8264f14aef03722a221a72e70c038875f11c3dbfc19e9872883",
"cmd/go-omni/projectgen/generate.go": "f4388d73901a0c5d5f3aac58d8ee1d1aa69ea395019875ec3c4cffc49fb1179d",
"cmd/go-omni/projectgen/generate_test.go": "f113258f0150a9f569dbe422b9f40e8ba0db3d115caf09a2c81432a8ae126e03",
"cmd/go-omni/projectgen/templates/.gitignore.tmpl": "82abf6aa84ec1c3ec218b09b5ce465af616c4fe8ff6de6db09abef962a137401",
"cmd/go-omni/projectgen/templates/README.md.tmpl": "3d01821fb4af9e97ca92f47e80a520d6f64ef136e49a8bcd4fed53dec32bde23",
"cmd/go-omni/projectgen/templates/etc_config.yaml.tmpl": "c224c68f50ea00a10e9dfd317347ac843c9107738861dab7824f7a7b1ae3a1ad",
"cmd/go-omni/projectgen/templates/internal_config_config.go.tmpl": "55111dbe02f0b91ed58a36449dcee8c9d7338251730764cda0de3e77e4a82f28",
"cmd/go-omni/projectgen/templates/internal_config_loadGen.go.tmpl": "95fb848065e3e516a8026f4439ede6d8be100e1726a56a124a6e49cb1d9d817d",
"cmd/go-omni/projectgen/templates/internal_config_loader.go.tmpl": "c9ad9f26a7d84622d7f2114a020b57ba6871a86ccf6cbc015c63674026ce81c3",
"cmd/go-omni/projectgen/templates/internal_run_run.go.tmpl": "51c2b4b407aa8b350e666be49b9d888ee42070627e28303ae2e1b0c9b4d73c95",
"cmd/go-omni/projectgen/templates/internal_svc_runtimeGen.go.tmpl": "8e259634ac7fbac0d2a13629864b2a5c89e43b6e9c477db9272b00cf49dc47d1",
"cmd/go-omni/projectgen/templates/internal_svc_serviceContext.go.tmpl": "1ed62fd86fd050468cac1c158a438deed01d4c25b4b634eec4ccd84b24df476f",
"cmd/go-omni/projectgen/templates/main.go.tmpl": "c59a7773f00c31c1a67e6a0093de26bc6f2dc8dbe34c61c1fa7e1b341e4afcc2",
"cmd/go-omni/projectgen/testdata/consumer_test.go": "38df45a69e843dfe6897d0296337e10b507076ea3c7a32901c1cad340915b0ee",
"scripts/verifyProgramRelease.py": "9127743ee929cb32701e4a063372a817ead3fd2795c2722b75f2a0c9123fa85a"
}
}

View File

@@ -0,0 +1,295 @@
{
"passed": true,
"frameworkVersion": "v0.28.2-preview",
"frameworkSum": "h1:QjHXXV184SO45GCXLyejeVDdCzLa5EtRu2ngyYpLzYI=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 160.57,
"checks": [
"remoteCLIInstall",
"freshModuleCache",
"fixedVersionNoReplace",
"contractChangePreview",
"persistentLogicSignatureFailure",
"businessSourcePreserved",
"fixedConsumerBuild",
"rootGoRun",
"nethttpAndNativeSignals",
"listenerReleased",
"default404And405",
"goOwnedErrorCatalog",
"unwrappedDownloads",
"rawCallback",
"multipart400And413",
"nethttpAndNativeTransfers",
"sharedGoCatalogExport",
"documentOnlyErrorSelection",
"workspaceCheckNoWrites",
"staleCatalogRejected",
"twoIndependentServiceBuilds",
"initializationAndListenFailureCleanup",
"drainBeforeDependencyClose",
"generatedConsumerRaceTests",
"errorParamsAndInheritance",
"manualHooksAndScalars",
"dependencyFatalShutdown",
"fatalProcessExitCode",
"initializationShutdownNotification",
"configuredBuiltinEndpoints",
"projectHealthEnvelope",
"endpointConflictCleanup",
"responseArrayNullableNull",
"primitiveAndDynamicArrays",
"strictRequestPolicies",
"declarativeMultipartBinding",
"backgroundStopDrainResourceOrder",
"backgroundInitAndListenFailureCleanup",
"backgroundTimeoutRetainsResourcesAndExits",
"generatedIncompleteHTTPDrainRetainsDependenciesAndLogger",
"recursiveResponseTypedHTTP",
"recursiveResponseReferenceGraph",
"recursiveResponseRegenerationAndDrift",
"remoteHTTPClientRaceTests",
"remoteHTTPProxyAndTimeouts",
"remoteHTTPHeaderIsolation",
"remoteHTTPPoolAndDrainBudgets",
"remoteHTTPExample",
"remoteExplicitConfigurationRaceTests",
"literalAndRequiredDefaultReferences",
"remoteCheckConfigBeforeDependencies",
"removedImplicitEnvironmentRules",
"remotePreciseModuleDiagnostics",
"remoteJSONSuccessStatuses",
"remoteRejectedStatusBodyCleanup",
"remoteCopyTypePreflight",
"remoteEmptyCollectionTypeRejection",
"remoteLoggerTimeZoneIsolation",
"remoteCORSPolicyRace",
"remoteCORSErrorResponses",
"remoteCORSTransports",
"generatedCORSConfigOnly",
"remoteUnifiedBodyPolicyTransports",
"remoteBodyLimitsBeforeMiddleware",
"remoteHardLimitConflictBeforeServing",
"remoteCanceledHandlerCleanupBarrier",
"remoteExternalServingResourceRetention",
"remoteAdmissionCloseRace",
"remoteRuntimeFinishRetainsHTTPDependencies",
"remoteHTTP2DisconnectedHandlerDrain",
"remoteNativeEarly413AndBusinessHTTP200",
"remoteHTTPSafetyExampleBuild",
"remoteTransactionStages",
"remoteTransactionCauseChains",
"remoteCommitOutcomeUnknown",
"remoteCancellationOutcomeUnknown",
"remoteTransactionPanicAndConcurrency",
"remoteTransactionPublicErrors",
"remoteTransactionExample",
"remoteI18nRuntimeRace",
"remoteI18nGeneratedConsumer",
"remoteI18nHTTPBusinessInvariants",
"remoteI18nSourceOnlyFallback",
"remoteCLDRSamplesAndBrowserConformance",
"remoteTypeScriptConsumer",
"remoteI18nCLIExtractionCheckExportTypes",
"remoteI18nNamespaceIsolation",
"remoteBrowserRaceAndFailureRetention",
"remoteJSONReaderCausePreservation",
"flatFieldComposition",
"copyxThreeEndpointConsumer",
"explicitNullAndMoneyPolicies",
"fieldAdditionWithoutCopyRuleChanges",
"middlewareFactoryInjectionAndCleanup",
"optionalRepositoryPreservesBusiness",
"generatedUnifiedConfig",
"jsonYamlXmlAndCustomDecoder",
"configValidationBeforeDependencies",
"configEnvProvenance",
"configExtensionPreserved",
"configLoaderDriftCheck"
],
"sourceHashes": {
"lifecycle/runtime.go": "d34fb26879932153671b9cbd164daae484b23dca08b8a3be5b1b89f8f6168e47",
"lifecycle/phases.go": "6e72499d2c5fcecb57cf712e76e3a03fed61a4caf528f873f2058291a8ab0b4d",
"config/lifecycle_test.go": "fe31c371acc3b2df2f58ecfb7b6a0f29d444410f43c0d49bad6cbf72a1a87248",
"task/task.go": "acb1a2cc0e26029331065545582c30e0c547e3b6869f7d8b16c7d25ab0077545",
"cmd/go-omni/scaffold/templates/api-runtime.go.tmpl": "e8575c04b0923d924d96e4334731313076284fd068c4990715b7a07a2724f969",
"cmd/go-omni/apigen/templates.go": "866a7ab9cc28f987645785203213e10722490970d5a1fd3c060d88db8b1278dd",
"config/project.go": "169e13921afa6a9336fcde3310675580799335ce55f076cbe60cdf3cfbd8cac2",
"contract/bind.go": "25ef07654abe42fe5f7e9cf5e5aa3bf102b311502bdbf16c0346970f87ee3053",
"contract/policy.go": "2c8e7efb07d59fea2c00ab39694d4df52a5bea6495ce23cac1c470584af74da1",
"contract/multipart.go": "db0377599b1b8b39246d41129513e8187109e44c1892971244d581c22ccc8ecd",
"contract/file.go": "20dd2de17e4854d452afc3c65f0293d504fb8fc25e91583ef45207fdab30948d",
"cmd/go-omni/apigen/business_test.go": "3b484352282d945f5a58047682079e3d64d4b0bf6ec6bafc23151b1197bb3942",
"cmd/go-omni/apigen/generate.go": "20579cb9509382ec75cae5d21e84da4eaed470b854978ce55398bdfdc6d68ebf",
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
"httpx/response.go": "99e29724e4fa51d2c7ec1a52892c24ac5daf210a2ee547c5e69a175015298efd",
"app/app.go": "2e672b3f6376049dd9695f799698439909f1d649d4338d13478e0dbf1de68800",
"app/transport.go": "8c665f47447d9839bba7ba3efa290316be9635179d2276b0da0d0f8eda3842ec",
"scripts/verifyManagedRelease.py": "a726d2a7480926edd0538a737bf381866e83ac32defd4e34b1901c9d445012c5",
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
"cmd/go-omni/apigen/managed_test.go": "e0b2b5aa6c4d6383590b467d57d7b4e00505ca35d6b6cde2475e4ad574041e94",
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
"cmd/go-omni/apigen/testdata/managed/background.go": "fb357b5deefd5187f2df406be5189fc4d57da857bd55b474f4726bf065c77167",
"cmd/go-omni/apigen/testdata/managed/client.go": "cb4a07539adbd163c13fc6cbb914518506b0c60a88473589f212f3406e763e4b",
"cmd/go-omni/apigen/testdata/managed/phases_test.go": "94a5787218c0d92159b473d390197858241ede2cab591df0976e611ed279dcfe",
"cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "b078bd9052e513ab6173a1e65cf54b28d3cfad445cb50518d9e2e76736071047",
"cmd/go-omni/apigen/testdata/managed/consumer_test.go": "589e2c54d9ad7f64e88ac556329a52062a27b4e898deb9a97feff200787c32c1",
"cmd/go-omni/apigen/testdata/managed/operations.go": "a1d91744abe020cbfb476512cffb7dad422a95da1c3be6957fdcfe27c361030b",
"cmd/go-omni/apigen/testdata/business/accessFactory.go": "2f982cf39b5e1a4be98596ce5d0a296af0fbf8c94616543a268e67ec376f31b4",
"cmd/go-omni/apigen/testdata/business/client.go": "d4cbaeec18dfb3a0e2821217e8eb0515000e351a745a93ec811dabca44e58c8a",
"cmd/go-omni/apigen/testdata/business/consumer_test.go": "e4159862a729ca2c5c9699840cc16a56350fc2914c43841f08946e9a6e9c8091",
"cmd/go-omni/apigen/testdata/business/operations.go": "11ded7921a0753f22975c525e334e3d45366143625e41d24fe500504e785c64c",
"cmd/go-omni/apigen/responseShape.go": "b0d573b5dbdfe397b0ca2bfc2e79c85c589bd11b2269f7206b3828cf0ff5afd7",
"cmd/go-omni/apigen/documentRefs.go": "7b841a668154d872493104eda0f9049340c67d9323eb99f1621a1ef6ba343891",
"cmd/go-omni/apigen/catalogDocument.go": "94df60328b2f105c35b73d9c6362ea14abadce6cb18e4d9921193d2d46854252",
"cmd/go-omni/apigen/workspaceCatalog.go": "c00784220b379ed80deb07c060532b756cbc47434ecb8b1ca10bc12b12159328",
"config/references.go": "ea709ddb32a9c5601662a875765d376b8490c4c766ecdb4365f462b5a2b10e16",
"cmd/go-omni/apigen/arrayResponse_test.go": "9e3e4475f76683d3346d349cdfa94e9e75cf913ac6979bd7a4ed86628466d879",
"cmd/go-omni/apigen/testdata/arrays/values.go": "d5e7f3adf12aabb301f46c296e8c894a7b2f6f70a6b6e7e02c74da7f1fd17bc5",
"cmd/go-omni/apigen/testdata/arrays/consumer_test.go": "435932f2952ad897943e2cda83b04eccb2950579d8d88772661541e1e2a2333d",
"cmd/go-omni/apigen/composition.go": "7ed3761b27d9c932d8ddc43e63c2aca798bdbe38f2b40571af8916c8401ea650",
"cmd/go-omni/apigen/generatorConfig.go": "78db0c7808cf6ec997c5b64a4a5fbea11b74049709bc5900ccfbc5d8dabebbc8",
"cmd/go-omni/apigen/copyConsumer_test.go": "e08ae4daed5dd25ab89d8872f653caf19499984dcaddc9835c13884c410fc0c3",
"cmd/go-omni/apigen/generatorConfig_test.go": "a6d8c8ebea5f1accf0e37dd044e57b48e422821fe75086d8d2eb3abe3acb147c",
"cmd/go-omni/apigen/middlewareFactory.go": "cc0220edb3eff25d7f66b2c37e206455f8730a2b909613713322bee7caf72303",
"cmd/go-omni/apigen/testdata/composition/accessFactory.go": "e1e5057ff79f806b3218e93249e8191d097b5c4844977204a8c32848443cba33",
"cmd/go-omni/apigen/testdata/composition/domain.go": "ab27ebd7ad5e3ed5505a2aa268b9b5ceedbb01477b59011bec63bb270114078b",
"cmd/go-omni/apigen/testdata/composition/amount.go": "f2d1834502d71d9ecf72ebe3b1077e5ef41f44fd816abd4617ac19f9bb962fdd",
"cmd/go-omni/apigen/testdata/composition/copy.go": "32d37d6f1f0e9aa2409aeb90b052801d1cdca70e504c2dd294cdae01422c9cb7",
"cmd/go-omni/apigen/testdata/composition/client.go": "19102e191c0471cae9e7405623906d0d804865c40b80ff1d5b240a41ceab6a4e",
"cmd/go-omni/apigen/testdata/composition/service.api": "01d230d5409756e2f3eb602a7fe065efa7d9599f7a4089da4bf8f07a2474444e",
"cmd/go-omni/apigen/testdata/composition/consumer_test.go": "74aaf60318cd0de67495fc9142b3f67c3b6e232bb48b46b3e134de5aadd225ad",
"cmd/go-omni/apigen/testdata/composition/dependencies.go": "edc153204b06da00c0e45c52db43a4a22f8ffe22c4a6d3f3ebec0137b861f84d",
"cmd/go-omni/apigen/testdata/composition/converters.go": "ecdcfe31c75ca815dab8d1a82533b12cbbd40e12b3a5c444e815097f6c4df08e",
"copyx/check_test.go": "4095de3e3ba946573edb0b2baad5b492eb559565745693e144ca008eb740e1a2",
"copyx/check.go": "aa53985a2d8b73a18c51d112552bfecd06b60079cd119ebeed5ef4a5d3b0db22",
"copyx/options.go": "edea2f3d2043bf813e56dedf0aea319aaa52301d3b589f41fef986e33a77dc32",
"copyx/copy.go": "179a5a1be871813765074d945aa341945d2a4a9a42928e2b30e9839f9795aa9a",
"copyx/graph.go": "ae6a2a11a8175033d327b580cd570c97fe9d2ddb814bb50884cc19a90766badd",
"copyx/benchmark_test.go": "b1bf408957cadea47b47215ffb099d140d5d1a2eb061024a6d0c1d769f469a79",
"copyx/copy_test.go": "89b65a6281591d7cd5823cc1c7d2c384c6bd446ef06aa1763ddc3acaa84dfb4a",
"config/decoders.go": "6d31f67c82563c2d63e37ad1ce9b9fbad6232b2b785b9b58dd025be857f0fc28",
"config/config.go": "cbf09c158a9add8289befd9960f911a8cf42de0b840a57278bc71bf800121ec8",
"config/cors_test.go": "b40e76dd943f28c80a06eeb2dc74a536b217c4918c4a21d7de870404483a377a",
"config/errorCatalog_test.go": "a9cca5233da1ebf9280a022e10ff07e50f99cf01eac8ae70b905fe12665e3d9d",
"config/suggestions.go": "5220f78bc8e4fda44f4cedc6cc6227895417d83d49eaef3f54711f3a0f9e1245",
"config/http_test.go": "975dc288486ba537cf7fd7ee87df792ba7eceed6ab0a58e6a5c0660ce724ab9d",
"config/response_test.go": "b4ccb111f813f80dc180a7ddb11034a409b9409cbb1b57e654c04d678465d201",
"config/projectAdvanced_test.go": "48b5493fe364e1022d63f1f2b556e2e1554b14af8f994074ec9b048fc6108fa4",
"config/file_test.go": "019ad4356838a592826daaf3bdcc7706b74a0d7782abcbe9bd6aba161aaa6576",
"config/moduleDiagnostics_test.go": "68b9bba485617e3cde57773b8c24cd9554636f6364151bbfcad48548408c83b4",
"config/references_test.go": "e19f0a42bbe08d1a723aff63e2d77d22feba08da98ecf5d47aa675e7105da850",
"config/errorParams_test.go": "fe61921a2e5fbdf32a623299fec42799287b0af1ab19b2f8fdd4bc97583e582d",
"config/http.go": "d02858d522ad63e86185f5d87d3fec9718141c75b7199fe75d0894dfa016d24d",
"config/loader.go": "50a2347960cc98f337b7a179e3054af0bfd22db879b58bee4ffd09e3f693d051",
"config/file.go": "2fbf4cd55d13944d7a7a208696f4a845a2089058d5f6e463c1e769e56bf77404",
"config/diagnostic.go": "cf818cd90941a5c2d9a6279dc4c3b90d71495db22846625fdf769dadefcf077a",
"config/bind.go": "0eabe1534eb671f13c938021183a1f3df829283d6fb587b849d22253dda278b2",
"config/clone.go": "6146bf8a7d7549d975142a843db5c6f95c67e98b62cbd98e20c77842abd38040",
"config/endpoints_test.go": "3368cd6af3e5902a1a4f7ea9f67e2da9e3d525f5c63f7e78aaeaae8fdfbf8394",
"config/config_test.go": "112202bdd153e9e87a43629b842e02caaad54e9516e1f7cd18c301f74cb69cc7",
"config/loader_test.go": "854de1586e64ac00acd7970be929af8a1b17d9a027bf92544f6221ccef38ceb9",
"cmd/go-omni/apigen/configLoader_test.go": "75c47861be382558434883d25c8deb1a567bd305290db624e572d2dcf302afbf",
"cmd/go-omni/apigen/testdata/configuration/consumer_test.go": "0cf2d8b6fabf8dc4652d33cadb5a462e6f3a527463d8965fcb54123ae6cb6cc4",
"cmd/go-omni/scaffold/templates/api-load.go.tmpl": "c8089dfa50ea09de60a18f06463e83e8f0fa9fbb6795fd615e37822d7b2ab708",
"cmd/go-omni/scaffold/templates/api-loader.go.tmpl": "847ac407827808022dcf493823b58671e1ae50349b173a5e4500580e07c64150",
"httpc/config.go": "08241015b758648b0eb793e1856b88898ec57d8021c8e542db7d1dd6a2dcf2e6",
"httpc/request_test.go": "ae410eaa2cac54bbc15515a524387dde7bd7aa61a8fcc8e820108cbcc85c7b98",
"httpc/request.go": "43c1a4611331c8ac81eab98d5782fe8d4d271c14daa219e8a2134e029b65064d",
"httpc/status_test.go": "4f71d3f4aede466c02f978c5b4a4d3ffc83edee5916ce8d0505d01878ddef2a5",
"httpc/client.go": "a5a99b70bd1b1828a260bb41a6d9dc42d85f18a6b33ec822315374871d6af959",
"httpc/benchmark_test.go": "3ac0ccfb227b00fcbca42f89686fb67e3e00f4ed23ac5dfa686663ae18843a07",
"httpc/config_test.go": "84bc068fdc7964ae16c133374ecd6e07278253e33cea132ebcafc681378d7559",
"httpc/client_test.go": "972ebd2efab618629e394484c288afe4e675fcfe0c50121d9fa9071680d6e763",
"httpc/json.go": "e4a3caf19f24c3f58494311cd887dbb00acbdb4d9efc1f36988e19cb0f9abe7c",
"examples/httpClient/main.go": "8572f68d1bdbe9e3ce36b6f53d6ac7ef98966e0ac596ba57bb0811314a832eb5",
"examples/httpClient/main_test.go": "32f5513b2a2702214882c0163e0686548d8eb527977cde23829b54175d95010e",
"logx/logx_test.go": "0f99eff445decdd81a55b3d5374c22d2e038d2a4748adb2c9367d6d03e481671",
"logx/redaction.go": "e288c01cb0e30a79855b9e1b886a802c5614298aba5f39b2619eafb1f187f9ae",
"logx/adapters_test.go": "f0c57ca90075c82f9552ec43e58ab0613b43dea89411d9db50ba5fcb5be3dddf",
"logx/production_test.go": "5a6f957d409d657a1e712e72aab3e3275b2ba21dc81dd3be69523c90d47cc458",
"logx/timeZone_test.go": "523a864a1d55e32a770b9816634d6445ad468cab2e7d8d86588b1c428ec04824",
"logx/adapters.go": "5b558685e88cb37889238f73e9670d4db2b509fd39d512ddfca25de1b5f4aea8",
"logx/logx.go": "18dd28f8378e403773e238d1c8ce4acd05838d2863475ab7a5e278f5ba7cb6fb",
"logx/timeZone.go": "1b82737362f880f4a8461fd20637ef64eef5a5db475a49e0c9daa27a6ca7b636",
"logx/rotation.go": "e278c5ffbdba36c39b15c8b31299abe402695edaf525eb5b03d4328cf35fce61",
"internal/configdiag/error.go": "a25f9f398cd0e5ea1a0d049c8a7c264c85be67c469619b41220cf56afffdbda6",
"database/mysql/mysql.go": "e5f95b97dd79eb64d493255d2fd2f5895538131021eb8a6068de8ff6f484702c",
"database/connector.go": "5a973ea4bc53fee2b72f1925f5cf4e3d83a3c935a1455406f4a7abd46c47c007",
"database/database.go": "5cf05972b56870d89da5290e3b0db777b0253c3b4214cee3d47ab7fa6d76a263",
"examples/developerTools/config.yaml": "1e672ba1b0b16a417840a09468f785776c51408ee973b55b1910cb736a8e29d9",
"examples/developerTools/README.md": "3ba0d700dc3e3788a2a57367273b8d6b0f2acbf2f6cf38149edeef84ce898908",
"examples/developerTools/main.go": "a799d3106b4a64258b7c1cbad9c776cab840526795416b07b114628e3bd4c440",
"cors/cors.go": "a0feb191b5ecc43b0a0c889af5c1ec960fdc55bfa813aff49413008077e64f70",
"cors/config.go": "973300087dee87fd35bb56a2c40b6ace75f85b08e6749cc509421aab9135e2e4",
"cors/cors_test.go": "49b20eb72ba4c40b949a711fba0bdcd342d793a8a010771204e696c563b3e08c",
"app/cors_test.go": "7dcfcbb9c605a16bbd5455177a1f527dbb18f673e6ebb2b93d7b874c64c5d56a",
"cmd/go-omni/apigen/corsConsumer_test.go": "8b5ce07474f50d89ba1d7d155b433e7103785b350db13df8cacd527b7070e240",
"cmd/go-omni/apigen/testdata/cors/consumer_test.go": "677c559b8c6fc77aa6d8a69c0c1296f1fbefaa7f2f64cf80609dbe4027e254ab",
"examples/cors/main.go": "39d76c4e2a4929f18f2acbfe03ab10c50b7276e56345de4d12eb962b5f6cc5b7",
"examples/cors/config.yaml": "a78f83433810cb385ac9a8debbeb6932ebef71d85df073afbfc1d928fb7bf89f",
"scripts/corsBrowser/main.go": "2654778e9c32829f6dadefe572551f9feb891da110741a08bf9243c7284c3ba2",
"database/transaction.go": "037f14ed495e6d024e13e2033c2e469b138dc5c1e1d6f6a9a73e723e094e71e1",
"database/transaction_test.go": "b8fb122c145836c07ca062dc8c89c07ae7482885d33c3f2c239cca21e97b72d4",
"database/transactionResponse_test.go": "bc59b17605c5b5e051fc987caf6dafbbb5d71f4b1380a5ceec8f2dafaafb6af7",
"database/mysqlConnector_test.go": "db5a9f5a99fd46e8e84c4452663704172ddc26a57ad6af84d3165041ff5357e9",
"examples/transactions/main.go": "ac2a17556b63c0bbb8a5c726381b6929df235da089549937a70d3696e2cba3f2",
"examples/transactions/demoDriver.go": "950b5f9bf0d20b8b2bc7590d502e110f2815fc394df789cac39e324149870ab6",
"i18n/bundle.go": "cd0b54e779fa9f8e3a1b5d4c7456ae06f0f39618365362de329d4b61104abb93",
"i18n/types.go": "cc912f7c7dc598de758cec79568fd0bdfac6cab8a8333a6ce0570680236d415a",
"i18n/plural_test.go": "ad3a430f370bc51503aa5bce335dcb5962f6e8226f2925956ddd419fd9c230d8",
"i18n/export.go": "99608af1d487fced1bba49b77157e7a1f51afb4341fbb607f09920dfca94155a",
"i18n/pattern.go": "1c7a91e48f449124d2de90abebd698401aef93896fcdfcf24a83e12a048c5192",
"i18n/README.md": "11a933abe712c961ca1c3b138d4440bc440a7b4c6e30654849e604efe4be056c",
"i18n/plural.go": "9b69e23568a4ea240d5d4bd2c62237932245a8a91aa451386f4a7d9d76d774f9",
"i18n/load.go": "6e43d781ba90db6de78d2138e496161c391797a735211cae7342abebac2c5618",
"i18n/bundle_test.go": "f9e30d36955722f51e7d9dfe685515441451459547a3235b97064de7dda4d8f9",
"i18n/i18ntool/extract_test.go": "dd62077d190e4264348f18e8f86d25e324eebf2d471918f27da8f0c552bf46c6",
"i18n/i18ntool/extract.go": "f0ab49435f61750b06ee8105ff016be5fe06c8404f90163708d9f7e423ae89c0",
"i18n/errori18n/errors_test.go": "2c2b74b1752d4367035c9f6b9f0f79bd05dac583f37f4e4de97d2163e667be4d",
"i18n/errori18n/errors.go": "539a9eac6b841a566bc808363e9505226c7510d5dc277da621dbefb16cb06ecd",
"i18n/browser/runtime.d.mts": "fd2f18dbb6d1b1dfe2392a5e0831354f0fb2ac8815328d8b422ab79503889ad7",
"i18n/browser/runtime.test.mjs": "83b219f3b72cdeb417f6223f9f0942ef8d3b43d5051b1c66dc475c7634d65acf",
"i18n/browser/README.md": "8a3de821c6cdf57c99ee2b56fa0deeb7557b9d11a9a77bf05b00c1ae22bf34ab",
"i18n/browser/conformance.mjs": "95b7325ef24513b53eb5b8d3475fe3c5c7c56bc2da2cb1aa74e097b2ee410623",
"i18n/browser/assets.go": "a20c679902c5d96dba13b0df31ef98e3516f0ebdd1c403740a6a720a412b0b84",
"i18n/browser/runtime.mjs": "a38f6378a835661ee9198ff910eafd04f4e56051c113d0637703e59e4ae3cd3c",
"i18n/browser/testdata/consumer.mts": "84e3e26d2d86a03b30e3fcd27c3bb51c268ac89ef115f8def274321250b46e08",
"i18n/cldr/LICENSE": "220ba0e1c43b99530d2d5bdb892a99dca0989414f51ab695ecd90163eaa1ec3b",
"i18n/cldr/README.md": "cd5aef06e415cf21913a5b8736c29abbc02df449868c16cd08be8aa7e0c8fea0",
"i18n/cldr/plurals.json": "ce19668400e9f7904e8b3939d1f0a2872fe8da7bdea9f45f5d50b3a2bac3b382",
"i18n/httpi18n/http_test.go": "1f0266d2d2a54bfe767efe76bfedce6130f6c988dd04be801416336a95a5d606",
"i18n/httpi18n/http.go": "ac22eeb795b62b74d0b9a233b6fb79a337f35d430e6a136fb31f643ee4512f0d",
"examples/i18n/index.html": "7d0c9e1fdaf96c883cbe8e671ae094d83c2742a9f76b820649d58d62f4944c12",
"examples/i18n/config.json": "c3e12efcedbfc6a8ca73cae9cd4cc3617ee363af8747aec630808e6f5e1eb682",
"examples/i18n/README.md": "bf5c3ae49b921b1c25d06e19d5ecb743f349ea9d085b9d9da79f1391a29fa9e1",
"examples/i18n/main.go": "3f8599781e423a9e3a968f5310fcc80d2daf5f09b733050c1e13c4fea589f29e",
"examples/i18n/main_test.go": "c99f736841fe579d2134762d4962da73d6ab88090e5921ce8c26edf5134bee9b",
"examples/i18n/locales/ui.en.json": "fa8b210ad1a78baae8f444420c983c5498025a1a3ea0bd15d64d8125629bee42",
"examples/i18n/locales/ui.zh-Hans.json": "04761003bea2a02b1e746a1f458f50fa2e0fdb3706d02bc5cb5a4f16378c5021",
"examples/i18n/locales/errors.zh-Hans.json": "d9113b720f4c284b90c41a6db4a867d272db4e951cd284eeb00ca69e3fc21385",
"app/i18n_test.go": "4d6125b78c33c7df759584b84f791d9a041673d6536503c6671a71e20b588d39",
"errors/localization.go": "a047c33e9c4ad9ca10aacfa0800dec51a5db788c1299fba0fb9b361abef41c85",
"cmd/go-omni/i18n.go": "be95a89de3fcc34baaea76669f30616902b0f5d7d6ef018e9c29a8e38106fb6a",
"cmd/go-omni/apigen/i18nVersion.go": "c4187e5d77164b8e906294b63e8d351db49a6d587b4f77a32529f6a4b75cb1c9",
"cmd/go-omni/apigen/testdata/i18n/consumer_test.go": "b62ad30f5912c50e1f6bb1737fd75430ead113989990acb6dcdc93f5d88de0af",
"contract/policy_test.go": "d1ea81c923dda58ae75323cc1eb9773517e63180153dbdf19eb35a634c9890db",
"app/body.go": "4064b96fc38c5b75ec17f0d82e0dec79242640c4d0903bf544e4fda057e5397a",
"app/router.go": "b0418e18fd546b855a4cce9ff19efc25b274d9b9c52144e12338bb5d97a5be9f",
"app/requests.go": "c4cb453e8215ed594e6755b256b4b1bbcd3b870584f4be19480d4dd1b9e0aa3d",
"app/bodyPolicy_test.go": "505afa7bcc3f70009ef2af2c455e377277db01c2b883c8e5204de0cbebf25cbb",
"app/requests_test.go": "b008b5599e1f5eea01a08f15220588bd581a2d5e98658d53f0acb273dd946137",
"app/transport_test.go": "10f086eeb77e3b11e83768bbbb0372aa68056a8c8839a18edfaf597b5acff689",
"httpx/context.go": "e3a7d3cd31bc6bddaf21c2be35887cf5c85e69ec5e6ff82178884cb0e4696ffb",
"internal/httplimits/limits.go": "98ba40ceda78fcf4b08d51b0851de851329f568fd35b0a4aedefd4b48a43e780",
"lifecycle/runtime_test.go": "ccd302f78535215bea69d7c8ad8c812c79c52cca087353358206cb6b5926d34b",
"transport/http2/server.go": "e0b7fdb3069583d042df5117b36a3fffc423ee452b0a9da162e3604fcce80b49",
"transport/http2/lifecycle_test.go": "2a298b8e466ff4145105338904ec5d9e19087c91d829d6ae5cad8c8b1ee05b15",
"transport/native/server.go": "8c2fc503bc157265838b453bfe289ec68eeb2bdaf63f6dd1e585536a8278ba0c",
"transport/native/earlyReject_test.go": "0fd598e24199beb55bead10d741d739f7361345584c4430b4a97c35d59d62774",
"examples/httpSafety/main.go": "4651b3806e7a8030101ba6a70794fcc18a50d2939fe0a59d7f80bb4a7de30f15"
}
}

View File

@@ -0,0 +1,34 @@
{
"passed": true,
"frameworkVersion": "v0.28.2-preview",
"frameworkSum": "h1:QjHXXV184SO45GCXLyejeVDdCzLa5EtRu2ngyYpLzYI=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 66.19,
"checks": [
"remoteCLIInstall",
"remoteAPIModule",
"remoteGenericModule",
"explicitKinds",
"manifestInferredKinds",
"mixedFormatWithProjectSkip",
"mixedGeneration",
"mixedCheck",
"nativeGoWorkBuild",
"nativeGoWorkRaceTests",
"moduleAndWorkspaceMetadataPreserved",
"bothProjectFailuresAggregated",
"nonzeroFailureExit",
"failedCheckIsReadOnly",
"crossProjectPreflight",
"missingGeneratedOutputRestored"
],
"sourceHashes": {
"cmd/go-omni/apigen/workspace.go": "cc39732ed9a41bb2dfa14933a830a7f10e8e3b2edc2ada07c376a738c0ec77e0",
"cmd/go-omni/apigen/workspaceMixed_test.go": "119f2a2aadc2b3ee2471bed1309c6ad8e36488b258f2ab1ba5ba5bab4219643f",
"cmd/go-omni/apigen/workspaceCatalog_test.go": "372874322aece46714351f088b5741642fe4e63d95062743a575958fa58b8b39",
"cmd/go-omni/workspace.go": "b7bbd3d9d58b3987090edf7e3714d7d8396e7c332174022d918a52e16396397d",
"cmd/go-omni/workspace_test.go": "0170d18158325262765d8b3067f55ca3ce9fe994f2d030cafee3e82b13bfcd4b",
"scripts/verifyWorkspaceRelease.py": "0495dc97c68ac17a7969088cf94b1cbbfa6e53f18fb36147c64bc34edb260bca"
}
}

View File

@@ -0,0 +1,43 @@
# v0.29.0 通用签名读取验收
范围:Local、S3/R2、OSS 的签名读取选项和本地范围读取。接口不包含业务身份、租户、分享记录、数据库、撤销或固定有效期。旧 Presigner、旧本地链接和默认下载行为兼容。
## 本地回归
2026-09-30,macOS arm64,Go 1.27.1:
- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -count=1 ./...`:57 个测试包通过,apigen 集成包 123.143 秒。
- `GOTOOLCHAIN=go1.22.12 GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -count=1 ./...`:57 个测试包通过,apigen 集成包 113.212 秒。
- `go vet ./...`、`go build ./...`、`git diff --check`:通过。
- `go run ./examples/signedRead -check`:真实回环 HTTP 预览、中文文件名、下载、HEAD、Range 通过。
- `python3 scripts/verifyUploads.py --output reports/signedReadMinio.json`:真实隔离 MinIO 的新读取选项、原上传/分片与存储契约通过,见 [报告](signedReadMinio.json)和[日志](signedReadMinio.txt)。独立容器无外部网络、不映射端口,已清理,原有容器保持运行。
首次 MinIO 回归中,原有契约故意发送错误长度触发 SDK 重试退避,耗尽契约的 10 秒预算。测试专用 client 改为 MaxRetries=1,与现有协议测试一致;生产 client 配置未变,修正后通过。
## 覆盖内容
- 默认 attachment、inline、可选 MIME、ASCII/中文/特殊字符文件名,拒绝 CR/LF、控制字符、路径、过长参数、非法 MIME 和 disposition。
- 本地签名绑定版本、key、有效期、全部响应选项,重复参数、未知参数、签名篡改、版本剥离和旧下载 URL 注入 inline 均拒绝,拒绝前不读存储。
- 30 分钟实际签名到期时间、TTL 上下限、精确到期边界、取消 context;过期 Range/HEAD/条件请求不得读取文件或返回缓存命中。
- 本地真实文件 GET/HEAD 内容长度、前缀/后缀/开放区间/截断/多段范围、206/416,Seek 不暴露元数据,按范围读取而非全文件缓冲;顺序读取 Store 回退完整 200。
- 错误及条件响应保持 private/no-store,HEAD 成功与失败均无响应体。
- OSS SDK V1/V4 独立重新计算签名,验证响应头在签名前加入,中文文件名、配置域名、到期时间及选项篡改;拒绝未签名配置。S3/R2 验证原生 SDK 查询参数与实际到期时间。
- MinIO 实际 GET/Range 的响应头、字节和状态正确,篡改 disposition/type/cache/expiry 被服务端拒绝。
## 验证边界
OSS/R2 未使用云账号实测;SDK/MinIO 验证不能代替实际桶和域名验收。OSS 默认域名可能强制下载,其响应头覆盖受平台规则限制,需部署方检查实际 200/206。
浏览器控制工具不可用,隔离 Chrome/Chromium 截图尝试未获得成功渲染证据,因此不计为浏览器预览通过。真实 PDF 的 HTTP 响应已检查,浏览器视觉预览与实际云域名接入仍需项目侧回归。
## 发布验收
三个独立空模块缓存安装远程固定 `v0.29.0-preview`,均无本地 replace,全部通过:
- [综合远程报告](signedReadRemote.json):107 项检查、11 组生成消费者,138.76 秒。包括下载模块中的签名读取、旧链接兼容、篡改/过期、范围读取、OSS V1/V4、S3/R2 和真实 HTTP 示例,以及原有生成器、生命周期、配置、i18n 等回归。
- [通用工程报告](signedReadProgramRemote.json):30 项,31.82 秒。
- [混合工作区报告](signedReadWorkspaceRemote.json):16 项,25.55 秒。
首次综合检查因生成的基础消费者未引入可选云存储 SDK,缺少相应 go.sum 条目而中止。验收脚本增加固定版本的可选适配器依赖准备后,从空缓存重跑全部通过;框架发布代码未修改。
三次下载 checksum 一致:`h1:FV3AzbYWBFQ7qnF3T2FWypGv2PSecCJfcOuNa2yKzRk=`。固定标签对应代码 `f0c62152d72b1b50672c29f5191ad88f8be3eee4`。验收脚本、说明和远程报告另行提交,不移动标签。

View File

@@ -0,0 +1,111 @@
{
"status": "passed",
"startedAt": "2026-09-30T14:35:32.113432+00:00",
"architecture": "arm64",
"goVersion": "go version go1.27.1 darwin/arm64",
"image": "quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z",
"imageId": "sha256:8f08aee614800a237906bd48114d733e5ac5bfac4ccdf731f141b0e880d7a253",
"sourceSha256": {
"go.mod": "e3877788b18b605a007530f80b684f06a7ae308844c2959a8750ed55ae8037a3",
"go.sum": "53c136b153ec7c91731f7c927dfda540fbd683037f3fbd1f3d067b1ec7aabc15",
"internal/storagefixture/multipart.go": "da9ec938b3bdb872e10e4f874b6a0224121c14a631dcf4e86d7ebbc1cd34a770",
"internal/storagefixture/server.go": "defacc905246a643faf5371e437f9ce458f05461e0a922766034d1f9ffdaf003",
"storage/local/local.go": "5d1ca75d0d2ca87981eef84a60bf98d0feefa2a9594940d6bd231877f4f8ec02",
"storage/local/local_test.go": "3a2241840968df8e37fd67169c3ef708228449e81f65aff908c8b60d49434dc0",
"storage/local/read_test.go": "ef1f7feb0887ea4b1f30304dfaf542fe9788b6e17856c942f1cd98448656194f",
"storage/multipart.go": "3b718ebe6da681c89a3f797892c88e7acb15c295df2376d64ac35da0976ed69d",
"storage/multipart_test.go": "4053f680f7795db2a6b4aa23590b3669eec7a609dd92c4a1e20891a5299274ee",
"storage/oss/oss.go": "4216ce9f3fe3ac5418a5b809db3cef7e99dcca72a25a2a12301f5897cda85dd5",
"storage/oss/oss_test.go": "555a954010b1218804dc7951fc75e42eae24eba9a99c0871f5ba787f3fc87d45",
"storage/oss/read.go": "429a9a9f6c586a885d7cccb76c9d572d53a79df637ff128caacb2eac35b8d862",
"storage/oss/read_test.go": "a1c273cfedd2f3ee31ce407a7ddd6fd5ad02c65ad632a91bc7f3ead3712b5bbd",
"storage/oss/upload.go": "556129611f9e5549cac8a36b4026bfb19c112a295b55192579f9ac116e20626f",
"storage/oss/upload_test.go": "08eb0ed157f3743288d781f61d4d2b7bfe77852a8259c5e598c719d35889409e",
"storage/read.go": "92787c74ae024822f82b5b84bffc9f8a355f1f2565b99ca7fbefe49157ef4851",
"storage/read_test.go": "9b11ab9640e98c1c9fa7ce1e2587df217e874e3a6a6e27a0dd0ade6fbf3d5e1a",
"storage/s3/config.go": "626ec6a2c0b06c95ee7eb7aa0832b190cfcdeb8a127b6b89e99204040bc3b041",
"storage/s3/read.go": "319b57f2ecf435b71085c7cdcafd50a1e30caa5f357f83d4db3cdcef085792b6",
"storage/s3/read_test.go": "9b64fda5c83ba41508ec23cf41aeeb9826b677caaa851dcbeb04abe1f43eed65",
"storage/s3/s3.go": "016bef5948e97f3d043d2e858716f1c02d9f27c4e02802bdc1be3860116f4b6c",
"storage/s3/s3_test.go": "9ef284926ec9936ddfec8c52a9bf04fc7b4be55c43a535f6c5a428d1535f6fec",
"storage/s3/upload.go": "3ce0b3626249ffe7a041606d6a9481743af8a0d4868fadea20ed3ed44ee0c765",
"storage/s3/upload_test.go": "a41fdbd100d9c79a3606c5942f98017afe26d841b251d96e979df87d0af93867",
"storage/signed.go": "ae4b144137627be35718c40743ebc2f063ec667c24b6d8984ccfcca034a73787",
"storage/signedUpload.go": "a8d31bcbbad64796f04611e69271e94d917c9bcd5aeabd7a97ea155b0093c814",
"storage/signed_test.go": "eaffd40894018814e976477f96cf0fbaecfa1987caad78830d9fc8e96995882f",
"storage/storage.go": "cb670872f01cf1d7b4ebf21cec9fc6f52ad93bfdf523b2f28bbb8d9c7fe39a6c",
"storage/storagetest/contract.go": "e7ad7242102437882269d4066462059d7974586b36a82d18639eca10fd932be9",
"storage/storagetest/uploads.go": "9b66f86dd94ccdf30c7d603d432bc2a55956608898e46c1274fa27d16fa1a969",
"storage/upload.go": "9ff2930ba389163acc8063b51107c09a60290609ba384de8004c498ce09f20a1",
"storage/upload_test.go": "12b60419807ed64b89e30861674e19abee7b0b7bb0e8ec9ac5b3aa2e0710a1da"
},
"commands": [
[
"go",
"test",
"-p=2",
"-c",
"-o",
"/var/folders/wm/1sg73x8n7879sp3t6m4jpnrr0000gn/T/goOmniUpload3cba6ibp/s3.test",
"./storage/s3"
],
[
"docker",
"run",
"--rm",
"--name",
"go-omni-upload-c388755df4ec",
"--platform",
"linux/arm64",
"--cpus",
"2",
"--memory",
"2048m",
"--memory-swap",
"2048m",
"--pids-limit",
"256",
"--network",
"none",
"--read-only",
"--cap-drop",
"ALL",
"--security-opt",
"no-new-privileges",
"--user",
"1000:1000",
"--tmpfs",
"/tmp:rw,nosuid,noexec,size=64m,mode=1777",
"--tmpfs",
"/data:rw,nosuid,noexec,size=128m,mode=1777",
"-v",
"/var/folders/wm/1sg73x8n7879sp3t6m4jpnrr0000gn/T/goOmniUpload3cba6ibp:/test:ro",
"-e",
"GOMAXPROCS=2",
"-e",
"MINIO_ROOT_USER=goOmniTest",
"-e",
"MINIO_ROOT_PASSWORD=goOmniTestSecret",
"-e",
"GO_OMNI_S3_TEST_ADDR=127.0.0.1:9000",
"--entrypoint",
"sh",
"quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z",
"-c",
"set -eu\nminio --version\nminio server /data --address 127.0.0.1:9000 --console-address 127.0.0.1:9001 >/tmp/minio.log 2>&1 &\nserver=$!\ntrap 'kill \"$server\" 2>/dev/null || true; wait \"$server\" 2>/dev/null || true' EXIT\n/test/s3.test -test.v -test.run '^TestS3Integration$' -test.count=1 -test.timeout=60s || { cat /tmp/minio.log; exit 1; }\n"
]
],
"containerLimits": {
"cpus": 2,
"memoryBytes": 2147483648,
"pids": 256,
"network": "none"
},
"notes": [
"MinIO verifies S3 signatures; OSS/R2 real cloud accounts are not exercised."
],
"preExistingContainersStillRunning": true,
"cleanedContainer": "go-omni-upload-c388755df4ec",
"finishedAt": "2026-09-30T14:35:34.723540+00:00",
"logSha256": "c25db6c44baee149c1e3d6d3b7f81faac9483eb79709bed25f61b03d7b9b3133"
}

View File

@@ -0,0 +1,7 @@
minio version RELEASE.2025-09-07T16-13-09Z (commit-id=07c3a429bfed433e49018cb0f78a52145d4bedeb)
Runtime: go1.24.6 linux/arm64
License: GNU AGPLv3 - https://www.gnu.org/licenses/agpl-3.0.html
Copyright: 2015-2025 MinIO, Inc.
=== RUN TestS3Integration
--- PASS: TestS3Integration (0.63s)
PASS

View File

@@ -0,0 +1,74 @@
{
"passed": true,
"frameworkVersion": "v0.29.0-preview",
"frameworkSum": "h1:FV3AzbYWBFQ7qnF3T2FWypGv2PSecCJfcOuNa2yKzRk=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 31.82,
"tests": [
"TestConflictRollsBackEarlierWrites",
"TestUnsafePaths",
"TestGeneratedProgramConsumer",
"TestOwnershipAndDrift",
"TestRecordedTemplateUpgrade",
"TestProjectGuards/module",
"TestProjectGuards/oldFramework",
"TestProjectGuards/manifest",
"TestProjectGuards/lock",
"TestProjectGuards/otherGenerator",
"TestProjectGuards/symlink",
"TestProjectGuards"
],
"checks": [
"remoteCLIInstall",
"installedVersionDefault",
"generatedConsumerBuildRaceVet",
"defaultNoAPIFiles",
"defaultNoNetworkListener",
"finiteProgram",
"SIGINT",
"SIGTERM",
"initializationFailureAndPanic",
"runErrorPanicGoexit",
"fatalComponentFailure",
"nonzeroProcessFailureAndTimeoutExits",
"partialStartupDraining",
"acknowledgementBeforeResourceClose",
"workContextSurvivesGracefulStop",
"cancellationGrace",
"unresponsiveRunAndBackgroundWork",
"retainedResourcesAndLogger",
"explicitConfigurationOnly",
"checkConfigWithoutDependencies",
"handwrittenFilesPreserved",
"generatedFileConflict",
"missingGeneratedFileRestored",
"missingHandwrittenFileNotRecreated",
"readOnlyCheckAndDryRun",
"templateDriftUpgrade",
"manifestIdentityAndVersionGuards",
"symlinkAndLockProtection",
"fileTransactionRollback",
"publishedExampleFiniteRun"
],
"sourceHashes": {
"cmd/go-omni/internal/genfs/files.go": "97e338c74ddb3145eb0ec942b69d5c21c83dfb2f9f2165376fc83366ae1e93af",
"cmd/go-omni/internal/genfs/files_test.go": "a659b31ddf195c80d48cfc00302a3f4c6d5a1ed68b58d241da03a44ad54fe34a",
"cmd/go-omni/project.go": "07e362664053e59dbb8f8f46d4c7be9d66afb93a7142a810109b66cd296ae8a6",
"cmd/go-omni/projectgen/consumer_test.go": "312442d56599c8264f14aef03722a221a72e70c038875f11c3dbfc19e9872883",
"cmd/go-omni/projectgen/generate.go": "da0cfb726c81ebe1b3dcf306130650f65d67c70df7bdc6b0899251d75d7f9b94",
"cmd/go-omni/projectgen/generate_test.go": "f113258f0150a9f569dbe422b9f40e8ba0db3d115caf09a2c81432a8ae126e03",
"cmd/go-omni/projectgen/templates/.gitignore.tmpl": "82abf6aa84ec1c3ec218b09b5ce465af616c4fe8ff6de6db09abef962a137401",
"cmd/go-omni/projectgen/templates/README.md.tmpl": "3d01821fb4af9e97ca92f47e80a520d6f64ef136e49a8bcd4fed53dec32bde23",
"cmd/go-omni/projectgen/templates/etc_config.yaml.tmpl": "c224c68f50ea00a10e9dfd317347ac843c9107738861dab7824f7a7b1ae3a1ad",
"cmd/go-omni/projectgen/templates/internal_config_config.go.tmpl": "55111dbe02f0b91ed58a36449dcee8c9d7338251730764cda0de3e77e4a82f28",
"cmd/go-omni/projectgen/templates/internal_config_loadGen.go.tmpl": "95fb848065e3e516a8026f4439ede6d8be100e1726a56a124a6e49cb1d9d817d",
"cmd/go-omni/projectgen/templates/internal_config_loader.go.tmpl": "c9ad9f26a7d84622d7f2114a020b57ba6871a86ccf6cbc015c63674026ce81c3",
"cmd/go-omni/projectgen/templates/internal_run_run.go.tmpl": "51c2b4b407aa8b350e666be49b9d888ee42070627e28303ae2e1b0c9b4d73c95",
"cmd/go-omni/projectgen/templates/internal_svc_runtimeGen.go.tmpl": "8e259634ac7fbac0d2a13629864b2a5c89e43b6e9c477db9272b00cf49dc47d1",
"cmd/go-omni/projectgen/templates/internal_svc_serviceContext.go.tmpl": "1ed62fd86fd050468cac1c158a438deed01d4c25b4b634eec4ccd84b24df476f",
"cmd/go-omni/projectgen/templates/main.go.tmpl": "c59a7773f00c31c1a67e6a0093de26bc6f2dc8dbe34c61c1fa7e1b341e4afcc2",
"cmd/go-omni/projectgen/testdata/consumer_test.go": "38df45a69e843dfe6897d0296337e10b507076ea3c7a32901c1cad340915b0ee",
"scripts/verifyProgramRelease.py": "9127743ee929cb32701e4a063372a817ead3fd2795c2722b75f2a0c9123fa85a"
}
}

View File

@@ -0,0 +1,331 @@
{
"passed": true,
"frameworkVersion": "v0.29.0-preview",
"frameworkSum": "h1:FV3AzbYWBFQ7qnF3T2FWypGv2PSecCJfcOuNa2yKzRk=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 138.76,
"checks": [
"remoteCLIInstall",
"freshModuleCache",
"fixedVersionNoReplace",
"contractChangePreview",
"persistentLogicSignatureFailure",
"businessSourcePreserved",
"fixedConsumerBuild",
"rootGoRun",
"nethttpAndNativeSignals",
"listenerReleased",
"default404And405",
"goOwnedErrorCatalog",
"unwrappedDownloads",
"rawCallback",
"multipart400And413",
"nethttpAndNativeTransfers",
"sharedGoCatalogExport",
"documentOnlyErrorSelection",
"workspaceCheckNoWrites",
"staleCatalogRejected",
"twoIndependentServiceBuilds",
"initializationAndListenFailureCleanup",
"drainBeforeDependencyClose",
"generatedConsumerRaceTests",
"errorParamsAndInheritance",
"manualHooksAndScalars",
"dependencyFatalShutdown",
"fatalProcessExitCode",
"initializationShutdownNotification",
"configuredBuiltinEndpoints",
"projectHealthEnvelope",
"endpointConflictCleanup",
"responseArrayNullableNull",
"primitiveAndDynamicArrays",
"strictRequestPolicies",
"declarativeMultipartBinding",
"backgroundStopDrainResourceOrder",
"backgroundInitAndListenFailureCleanup",
"backgroundTimeoutRetainsResourcesAndExits",
"generatedIncompleteHTTPDrainRetainsDependenciesAndLogger",
"recursiveResponseTypedHTTP",
"recursiveResponseReferenceGraph",
"recursiveResponseRegenerationAndDrift",
"remoteHTTPClientRaceTests",
"remoteHTTPProxyAndTimeouts",
"remoteHTTPHeaderIsolation",
"remoteHTTPPoolAndDrainBudgets",
"remoteHTTPExample",
"remoteExplicitConfigurationRaceTests",
"literalAndRequiredDefaultReferences",
"remoteCheckConfigBeforeDependencies",
"removedImplicitEnvironmentRules",
"remoteSignedReadOptionsAndLegacyLinks",
"remoteReadSignatureTamperingAndExpiry",
"remoteLocalPayloadRangesAndHEAD",
"remoteOSSNativeV1V4Signatures",
"remoteS3R2ReadOptions",
"remoteSignedReadHTTPExample",
"remotePreciseModuleDiagnostics",
"remoteJSONSuccessStatuses",
"remoteRejectedStatusBodyCleanup",
"remoteCopyTypePreflight",
"remoteEmptyCollectionTypeRejection",
"remoteLoggerTimeZoneIsolation",
"remoteCORSPolicyRace",
"remoteCORSErrorResponses",
"remoteCORSTransports",
"generatedCORSConfigOnly",
"remoteUnifiedBodyPolicyTransports",
"remoteBodyLimitsBeforeMiddleware",
"remoteHardLimitConflictBeforeServing",
"remoteCanceledHandlerCleanupBarrier",
"remoteExternalServingResourceRetention",
"remoteAdmissionCloseRace",
"remoteRuntimeFinishRetainsHTTPDependencies",
"remoteHTTP2DisconnectedHandlerDrain",
"remoteNativeEarly413AndBusinessHTTP200",
"remoteHTTPSafetyExampleBuild",
"remoteTransactionStages",
"remoteTransactionCauseChains",
"remoteCommitOutcomeUnknown",
"remoteCancellationOutcomeUnknown",
"remoteTransactionPanicAndConcurrency",
"remoteTransactionPublicErrors",
"remoteTransactionExample",
"remoteI18nRuntimeRace",
"remoteI18nGeneratedConsumer",
"remoteI18nHTTPBusinessInvariants",
"remoteI18nSourceOnlyFallback",
"remoteCLDRSamplesAndBrowserConformance",
"remoteTypeScriptConsumer",
"remoteI18nCLIExtractionCheckExportTypes",
"remoteI18nNamespaceIsolation",
"remoteBrowserRaceAndFailureRetention",
"remoteJSONReaderCausePreservation",
"flatFieldComposition",
"copyxThreeEndpointConsumer",
"explicitNullAndMoneyPolicies",
"fieldAdditionWithoutCopyRuleChanges",
"middlewareFactoryInjectionAndCleanup",
"optionalRepositoryPreservesBusiness",
"generatedUnifiedConfig",
"jsonYamlXmlAndCustomDecoder",
"configValidationBeforeDependencies",
"configEnvProvenance",
"configExtensionPreserved",
"configLoaderDriftCheck"
],
"sourceHashes": {
"lifecycle/runtime.go": "d34fb26879932153671b9cbd164daae484b23dca08b8a3be5b1b89f8f6168e47",
"lifecycle/phases.go": "6e72499d2c5fcecb57cf712e76e3a03fed61a4caf528f873f2058291a8ab0b4d",
"config/lifecycle_test.go": "fe31c371acc3b2df2f58ecfb7b6a0f29d444410f43c0d49bad6cbf72a1a87248",
"task/task.go": "acb1a2cc0e26029331065545582c30e0c547e3b6869f7d8b16c7d25ab0077545",
"cmd/go-omni/scaffold/templates/api-runtime.go.tmpl": "e8575c04b0923d924d96e4334731313076284fd068c4990715b7a07a2724f969",
"cmd/go-omni/apigen/templates.go": "866a7ab9cc28f987645785203213e10722490970d5a1fd3c060d88db8b1278dd",
"config/project.go": "169e13921afa6a9336fcde3310675580799335ce55f076cbe60cdf3cfbd8cac2",
"contract/bind.go": "25ef07654abe42fe5f7e9cf5e5aa3bf102b311502bdbf16c0346970f87ee3053",
"contract/policy.go": "2c8e7efb07d59fea2c00ab39694d4df52a5bea6495ce23cac1c470584af74da1",
"contract/multipart.go": "db0377599b1b8b39246d41129513e8187109e44c1892971244d581c22ccc8ecd",
"contract/file.go": "20dd2de17e4854d452afc3c65f0293d504fb8fc25e91583ef45207fdab30948d",
"cmd/go-omni/apigen/business_test.go": "3b484352282d945f5a58047682079e3d64d4b0bf6ec6bafc23151b1197bb3942",
"cmd/go-omni/apigen/generate.go": "c97c8c797105516e8e6870efffd662c7894e99d882a36207dee0162016b728fd",
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
"httpx/response.go": "99e29724e4fa51d2c7ec1a52892c24ac5daf210a2ee547c5e69a175015298efd",
"app/app.go": "2e672b3f6376049dd9695f799698439909f1d649d4338d13478e0dbf1de68800",
"app/transport.go": "8c665f47447d9839bba7ba3efa290316be9635179d2276b0da0d0f8eda3842ec",
"scripts/verifyManagedRelease.py": "4ef18cd08909ce0792af150539219c542facecc98204f1dd938c0646acc32e4e",
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
"cmd/go-omni/apigen/managed_test.go": "e0b2b5aa6c4d6383590b467d57d7b4e00505ca35d6b6cde2475e4ad574041e94",
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
"cmd/go-omni/apigen/testdata/managed/background.go": "fb357b5deefd5187f2df406be5189fc4d57da857bd55b474f4726bf065c77167",
"cmd/go-omni/apigen/testdata/managed/client.go": "cb4a07539adbd163c13fc6cbb914518506b0c60a88473589f212f3406e763e4b",
"cmd/go-omni/apigen/testdata/managed/phases_test.go": "94a5787218c0d92159b473d390197858241ede2cab591df0976e611ed279dcfe",
"cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "b078bd9052e513ab6173a1e65cf54b28d3cfad445cb50518d9e2e76736071047",
"cmd/go-omni/apigen/testdata/managed/consumer_test.go": "589e2c54d9ad7f64e88ac556329a52062a27b4e898deb9a97feff200787c32c1",
"cmd/go-omni/apigen/testdata/managed/operations.go": "a1d91744abe020cbfb476512cffb7dad422a95da1c3be6957fdcfe27c361030b",
"cmd/go-omni/apigen/testdata/business/accessFactory.go": "2f982cf39b5e1a4be98596ce5d0a296af0fbf8c94616543a268e67ec376f31b4",
"cmd/go-omni/apigen/testdata/business/client.go": "d4cbaeec18dfb3a0e2821217e8eb0515000e351a745a93ec811dabca44e58c8a",
"cmd/go-omni/apigen/testdata/business/consumer_test.go": "e4159862a729ca2c5c9699840cc16a56350fc2914c43841f08946e9a6e9c8091",
"cmd/go-omni/apigen/testdata/business/operations.go": "11ded7921a0753f22975c525e334e3d45366143625e41d24fe500504e785c64c",
"cmd/go-omni/apigen/responseShape.go": "b0d573b5dbdfe397b0ca2bfc2e79c85c589bd11b2269f7206b3828cf0ff5afd7",
"cmd/go-omni/apigen/documentRefs.go": "7b841a668154d872493104eda0f9049340c67d9323eb99f1621a1ef6ba343891",
"cmd/go-omni/apigen/catalogDocument.go": "94df60328b2f105c35b73d9c6362ea14abadce6cb18e4d9921193d2d46854252",
"cmd/go-omni/apigen/workspaceCatalog.go": "c00784220b379ed80deb07c060532b756cbc47434ecb8b1ca10bc12b12159328",
"config/references.go": "ea709ddb32a9c5601662a875765d376b8490c4c766ecdb4365f462b5a2b10e16",
"cmd/go-omni/apigen/arrayResponse_test.go": "9e3e4475f76683d3346d349cdfa94e9e75cf913ac6979bd7a4ed86628466d879",
"cmd/go-omni/apigen/testdata/arrays/values.go": "d5e7f3adf12aabb301f46c296e8c894a7b2f6f70a6b6e7e02c74da7f1fd17bc5",
"cmd/go-omni/apigen/testdata/arrays/consumer_test.go": "435932f2952ad897943e2cda83b04eccb2950579d8d88772661541e1e2a2333d",
"cmd/go-omni/apigen/composition.go": "7ed3761b27d9c932d8ddc43e63c2aca798bdbe38f2b40571af8916c8401ea650",
"cmd/go-omni/apigen/generatorConfig.go": "78db0c7808cf6ec997c5b64a4a5fbea11b74049709bc5900ccfbc5d8dabebbc8",
"cmd/go-omni/apigen/copyConsumer_test.go": "e08ae4daed5dd25ab89d8872f653caf19499984dcaddc9835c13884c410fc0c3",
"cmd/go-omni/apigen/generatorConfig_test.go": "a6d8c8ebea5f1accf0e37dd044e57b48e422821fe75086d8d2eb3abe3acb147c",
"cmd/go-omni/apigen/middlewareFactory.go": "cc0220edb3eff25d7f66b2c37e206455f8730a2b909613713322bee7caf72303",
"cmd/go-omni/apigen/testdata/composition/accessFactory.go": "e1e5057ff79f806b3218e93249e8191d097b5c4844977204a8c32848443cba33",
"cmd/go-omni/apigen/testdata/composition/domain.go": "ab27ebd7ad5e3ed5505a2aa268b9b5ceedbb01477b59011bec63bb270114078b",
"cmd/go-omni/apigen/testdata/composition/amount.go": "f2d1834502d71d9ecf72ebe3b1077e5ef41f44fd816abd4617ac19f9bb962fdd",
"cmd/go-omni/apigen/testdata/composition/copy.go": "32d37d6f1f0e9aa2409aeb90b052801d1cdca70e504c2dd294cdae01422c9cb7",
"cmd/go-omni/apigen/testdata/composition/client.go": "19102e191c0471cae9e7405623906d0d804865c40b80ff1d5b240a41ceab6a4e",
"cmd/go-omni/apigen/testdata/composition/service.api": "01d230d5409756e2f3eb602a7fe065efa7d9599f7a4089da4bf8f07a2474444e",
"cmd/go-omni/apigen/testdata/composition/consumer_test.go": "74aaf60318cd0de67495fc9142b3f67c3b6e232bb48b46b3e134de5aadd225ad",
"cmd/go-omni/apigen/testdata/composition/dependencies.go": "edc153204b06da00c0e45c52db43a4a22f8ffe22c4a6d3f3ebec0137b861f84d",
"cmd/go-omni/apigen/testdata/composition/converters.go": "ecdcfe31c75ca815dab8d1a82533b12cbbd40e12b3a5c444e815097f6c4df08e",
"copyx/check_test.go": "4095de3e3ba946573edb0b2baad5b492eb559565745693e144ca008eb740e1a2",
"copyx/check.go": "aa53985a2d8b73a18c51d112552bfecd06b60079cd119ebeed5ef4a5d3b0db22",
"copyx/options.go": "edea2f3d2043bf813e56dedf0aea319aaa52301d3b589f41fef986e33a77dc32",
"copyx/copy.go": "179a5a1be871813765074d945aa341945d2a4a9a42928e2b30e9839f9795aa9a",
"copyx/graph.go": "ae6a2a11a8175033d327b580cd570c97fe9d2ddb814bb50884cc19a90766badd",
"copyx/benchmark_test.go": "b1bf408957cadea47b47215ffb099d140d5d1a2eb061024a6d0c1d769f469a79",
"copyx/copy_test.go": "89b65a6281591d7cd5823cc1c7d2c384c6bd446ef06aa1763ddc3acaa84dfb4a",
"config/decoders.go": "6d31f67c82563c2d63e37ad1ce9b9fbad6232b2b785b9b58dd025be857f0fc28",
"config/config.go": "cbf09c158a9add8289befd9960f911a8cf42de0b840a57278bc71bf800121ec8",
"config/cors_test.go": "b40e76dd943f28c80a06eeb2dc74a536b217c4918c4a21d7de870404483a377a",
"config/errorCatalog_test.go": "a9cca5233da1ebf9280a022e10ff07e50f99cf01eac8ae70b905fe12665e3d9d",
"config/suggestions.go": "5220f78bc8e4fda44f4cedc6cc6227895417d83d49eaef3f54711f3a0f9e1245",
"config/http_test.go": "975dc288486ba537cf7fd7ee87df792ba7eceed6ab0a58e6a5c0660ce724ab9d",
"config/response_test.go": "b4ccb111f813f80dc180a7ddb11034a409b9409cbb1b57e654c04d678465d201",
"config/projectAdvanced_test.go": "48b5493fe364e1022d63f1f2b556e2e1554b14af8f994074ec9b048fc6108fa4",
"config/file_test.go": "019ad4356838a592826daaf3bdcc7706b74a0d7782abcbe9bd6aba161aaa6576",
"config/moduleDiagnostics_test.go": "68b9bba485617e3cde57773b8c24cd9554636f6364151bbfcad48548408c83b4",
"config/references_test.go": "e19f0a42bbe08d1a723aff63e2d77d22feba08da98ecf5d47aa675e7105da850",
"config/errorParams_test.go": "fe61921a2e5fbdf32a623299fec42799287b0af1ab19b2f8fdd4bc97583e582d",
"config/http.go": "d02858d522ad63e86185f5d87d3fec9718141c75b7199fe75d0894dfa016d24d",
"config/loader.go": "50a2347960cc98f337b7a179e3054af0bfd22db879b58bee4ffd09e3f693d051",
"config/file.go": "2fbf4cd55d13944d7a7a208696f4a845a2089058d5f6e463c1e769e56bf77404",
"config/diagnostic.go": "cf818cd90941a5c2d9a6279dc4c3b90d71495db22846625fdf769dadefcf077a",
"config/bind.go": "0eabe1534eb671f13c938021183a1f3df829283d6fb587b849d22253dda278b2",
"config/clone.go": "6146bf8a7d7549d975142a843db5c6f95c67e98b62cbd98e20c77842abd38040",
"config/endpoints_test.go": "3368cd6af3e5902a1a4f7ea9f67e2da9e3d525f5c63f7e78aaeaae8fdfbf8394",
"config/config_test.go": "112202bdd153e9e87a43629b842e02caaad54e9516e1f7cd18c301f74cb69cc7",
"config/loader_test.go": "854de1586e64ac00acd7970be929af8a1b17d9a027bf92544f6221ccef38ceb9",
"cmd/go-omni/apigen/configLoader_test.go": "75c47861be382558434883d25c8deb1a567bd305290db624e572d2dcf302afbf",
"cmd/go-omni/apigen/testdata/configuration/consumer_test.go": "0cf2d8b6fabf8dc4652d33cadb5a462e6f3a527463d8965fcb54123ae6cb6cc4",
"cmd/go-omni/scaffold/templates/api-load.go.tmpl": "c8089dfa50ea09de60a18f06463e83e8f0fa9fbb6795fd615e37822d7b2ab708",
"cmd/go-omni/scaffold/templates/api-loader.go.tmpl": "847ac407827808022dcf493823b58671e1ae50349b173a5e4500580e07c64150",
"httpc/config.go": "08241015b758648b0eb793e1856b88898ec57d8021c8e542db7d1dd6a2dcf2e6",
"httpc/request_test.go": "ae410eaa2cac54bbc15515a524387dde7bd7aa61a8fcc8e820108cbcc85c7b98",
"httpc/request.go": "43c1a4611331c8ac81eab98d5782fe8d4d271c14daa219e8a2134e029b65064d",
"httpc/status_test.go": "4f71d3f4aede466c02f978c5b4a4d3ffc83edee5916ce8d0505d01878ddef2a5",
"httpc/client.go": "a5a99b70bd1b1828a260bb41a6d9dc42d85f18a6b33ec822315374871d6af959",
"httpc/benchmark_test.go": "3ac0ccfb227b00fcbca42f89686fb67e3e00f4ed23ac5dfa686663ae18843a07",
"httpc/config_test.go": "84bc068fdc7964ae16c133374ecd6e07278253e33cea132ebcafc681378d7559",
"httpc/client_test.go": "972ebd2efab618629e394484c288afe4e675fcfe0c50121d9fa9071680d6e763",
"httpc/json.go": "e4a3caf19f24c3f58494311cd887dbb00acbdb4d9efc1f36988e19cb0f9abe7c",
"examples/httpClient/main.go": "8572f68d1bdbe9e3ce36b6f53d6ac7ef98966e0ac596ba57bb0811314a832eb5",
"examples/httpClient/main_test.go": "32f5513b2a2702214882c0163e0686548d8eb527977cde23829b54175d95010e",
"logx/logx_test.go": "0f99eff445decdd81a55b3d5374c22d2e038d2a4748adb2c9367d6d03e481671",
"logx/redaction.go": "e288c01cb0e30a79855b9e1b886a802c5614298aba5f39b2619eafb1f187f9ae",
"logx/adapters_test.go": "f0c57ca90075c82f9552ec43e58ab0613b43dea89411d9db50ba5fcb5be3dddf",
"logx/production_test.go": "5a6f957d409d657a1e712e72aab3e3275b2ba21dc81dd3be69523c90d47cc458",
"logx/timeZone_test.go": "523a864a1d55e32a770b9816634d6445ad468cab2e7d8d86588b1c428ec04824",
"logx/adapters.go": "5b558685e88cb37889238f73e9670d4db2b509fd39d512ddfca25de1b5f4aea8",
"logx/logx.go": "18dd28f8378e403773e238d1c8ce4acd05838d2863475ab7a5e278f5ba7cb6fb",
"logx/timeZone.go": "1b82737362f880f4a8461fd20637ef64eef5a5db475a49e0c9daa27a6ca7b636",
"logx/rotation.go": "e278c5ffbdba36c39b15c8b31299abe402695edaf525eb5b03d4328cf35fce61",
"internal/configdiag/error.go": "a25f9f398cd0e5ea1a0d049c8a7c264c85be67c469619b41220cf56afffdbda6",
"database/mysql/mysql.go": "e5f95b97dd79eb64d493255d2fd2f5895538131021eb8a6068de8ff6f484702c",
"database/connector.go": "5a973ea4bc53fee2b72f1925f5cf4e3d83a3c935a1455406f4a7abd46c47c007",
"database/database.go": "5cf05972b56870d89da5290e3b0db777b0253c3b4214cee3d47ab7fa6d76a263",
"examples/developerTools/config.yaml": "1e672ba1b0b16a417840a09468f785776c51408ee973b55b1910cb736a8e29d9",
"examples/developerTools/README.md": "3ba0d700dc3e3788a2a57367273b8d6b0f2acbf2f6cf38149edeef84ce898908",
"examples/developerTools/main.go": "a799d3106b4a64258b7c1cbad9c776cab840526795416b07b114628e3bd4c440",
"cors/cors.go": "a0feb191b5ecc43b0a0c889af5c1ec960fdc55bfa813aff49413008077e64f70",
"cors/config.go": "973300087dee87fd35bb56a2c40b6ace75f85b08e6749cc509421aab9135e2e4",
"cors/cors_test.go": "49b20eb72ba4c40b949a711fba0bdcd342d793a8a010771204e696c563b3e08c",
"app/cors_test.go": "7dcfcbb9c605a16bbd5455177a1f527dbb18f673e6ebb2b93d7b874c64c5d56a",
"cmd/go-omni/apigen/corsConsumer_test.go": "8b5ce07474f50d89ba1d7d155b433e7103785b350db13df8cacd527b7070e240",
"cmd/go-omni/apigen/testdata/cors/consumer_test.go": "677c559b8c6fc77aa6d8a69c0c1296f1fbefaa7f2f64cf80609dbe4027e254ab",
"examples/cors/main.go": "39d76c4e2a4929f18f2acbfe03ab10c50b7276e56345de4d12eb962b5f6cc5b7",
"examples/cors/config.yaml": "a78f83433810cb385ac9a8debbeb6932ebef71d85df073afbfc1d928fb7bf89f",
"scripts/corsBrowser/main.go": "2654778e9c32829f6dadefe572551f9feb891da110741a08bf9243c7284c3ba2",
"database/transaction.go": "037f14ed495e6d024e13e2033c2e469b138dc5c1e1d6f6a9a73e723e094e71e1",
"database/transaction_test.go": "b8fb122c145836c07ca062dc8c89c07ae7482885d33c3f2c239cca21e97b72d4",
"database/transactionResponse_test.go": "bc59b17605c5b5e051fc987caf6dafbbb5d71f4b1380a5ceec8f2dafaafb6af7",
"database/mysqlConnector_test.go": "db5a9f5a99fd46e8e84c4452663704172ddc26a57ad6af84d3165041ff5357e9",
"examples/transactions/main.go": "ac2a17556b63c0bbb8a5c726381b6929df235da089549937a70d3696e2cba3f2",
"examples/transactions/demoDriver.go": "950b5f9bf0d20b8b2bc7590d502e110f2815fc394df789cac39e324149870ab6",
"i18n/bundle.go": "cd0b54e779fa9f8e3a1b5d4c7456ae06f0f39618365362de329d4b61104abb93",
"i18n/types.go": "cc912f7c7dc598de758cec79568fd0bdfac6cab8a8333a6ce0570680236d415a",
"i18n/plural_test.go": "ad3a430f370bc51503aa5bce335dcb5962f6e8226f2925956ddd419fd9c230d8",
"i18n/export.go": "99608af1d487fced1bba49b77157e7a1f51afb4341fbb607f09920dfca94155a",
"i18n/pattern.go": "1c7a91e48f449124d2de90abebd698401aef93896fcdfcf24a83e12a048c5192",
"i18n/README.md": "11a933abe712c961ca1c3b138d4440bc440a7b4c6e30654849e604efe4be056c",
"i18n/plural.go": "9b69e23568a4ea240d5d4bd2c62237932245a8a91aa451386f4a7d9d76d774f9",
"i18n/load.go": "6e43d781ba90db6de78d2138e496161c391797a735211cae7342abebac2c5618",
"i18n/bundle_test.go": "f9e30d36955722f51e7d9dfe685515441451459547a3235b97064de7dda4d8f9",
"i18n/i18ntool/extract_test.go": "dd62077d190e4264348f18e8f86d25e324eebf2d471918f27da8f0c552bf46c6",
"i18n/i18ntool/extract.go": "f0ab49435f61750b06ee8105ff016be5fe06c8404f90163708d9f7e423ae89c0",
"i18n/errori18n/errors_test.go": "2c2b74b1752d4367035c9f6b9f0f79bd05dac583f37f4e4de97d2163e667be4d",
"i18n/errori18n/errors.go": "539a9eac6b841a566bc808363e9505226c7510d5dc277da621dbefb16cb06ecd",
"i18n/browser/runtime.d.mts": "fd2f18dbb6d1b1dfe2392a5e0831354f0fb2ac8815328d8b422ab79503889ad7",
"i18n/browser/runtime.test.mjs": "83b219f3b72cdeb417f6223f9f0942ef8d3b43d5051b1c66dc475c7634d65acf",
"i18n/browser/README.md": "8a3de821c6cdf57c99ee2b56fa0deeb7557b9d11a9a77bf05b00c1ae22bf34ab",
"i18n/browser/conformance.mjs": "95b7325ef24513b53eb5b8d3475fe3c5c7c56bc2da2cb1aa74e097b2ee410623",
"i18n/browser/assets.go": "a20c679902c5d96dba13b0df31ef98e3516f0ebdd1c403740a6a720a412b0b84",
"i18n/browser/runtime.mjs": "a38f6378a835661ee9198ff910eafd04f4e56051c113d0637703e59e4ae3cd3c",
"i18n/browser/testdata/consumer.mts": "84e3e26d2d86a03b30e3fcd27c3bb51c268ac89ef115f8def274321250b46e08",
"i18n/cldr/LICENSE": "220ba0e1c43b99530d2d5bdb892a99dca0989414f51ab695ecd90163eaa1ec3b",
"i18n/cldr/README.md": "cd5aef06e415cf21913a5b8736c29abbc02df449868c16cd08be8aa7e0c8fea0",
"i18n/cldr/plurals.json": "ce19668400e9f7904e8b3939d1f0a2872fe8da7bdea9f45f5d50b3a2bac3b382",
"i18n/httpi18n/http_test.go": "1f0266d2d2a54bfe767efe76bfedce6130f6c988dd04be801416336a95a5d606",
"i18n/httpi18n/http.go": "ac22eeb795b62b74d0b9a233b6fb79a337f35d430e6a136fb31f643ee4512f0d",
"examples/i18n/index.html": "7d0c9e1fdaf96c883cbe8e671ae094d83c2742a9f76b820649d58d62f4944c12",
"examples/i18n/config.json": "c3e12efcedbfc6a8ca73cae9cd4cc3617ee363af8747aec630808e6f5e1eb682",
"examples/i18n/README.md": "bf5c3ae49b921b1c25d06e19d5ecb743f349ea9d085b9d9da79f1391a29fa9e1",
"examples/i18n/main.go": "3f8599781e423a9e3a968f5310fcc80d2daf5f09b733050c1e13c4fea589f29e",
"examples/i18n/main_test.go": "c99f736841fe579d2134762d4962da73d6ab88090e5921ce8c26edf5134bee9b",
"examples/i18n/locales/ui.en.json": "fa8b210ad1a78baae8f444420c983c5498025a1a3ea0bd15d64d8125629bee42",
"examples/i18n/locales/ui.zh-Hans.json": "04761003bea2a02b1e746a1f458f50fa2e0fdb3706d02bc5cb5a4f16378c5021",
"examples/i18n/locales/errors.zh-Hans.json": "d9113b720f4c284b90c41a6db4a867d272db4e951cd284eeb00ca69e3fc21385",
"app/i18n_test.go": "4d6125b78c33c7df759584b84f791d9a041673d6536503c6671a71e20b588d39",
"errors/localization.go": "a047c33e9c4ad9ca10aacfa0800dec51a5db788c1299fba0fb9b361abef41c85",
"cmd/go-omni/i18n.go": "be95a89de3fcc34baaea76669f30616902b0f5d7d6ef018e9c29a8e38106fb6a",
"cmd/go-omni/apigen/i18nVersion.go": "c4187e5d77164b8e906294b63e8d351db49a6d587b4f77a32529f6a4b75cb1c9",
"cmd/go-omni/apigen/testdata/i18n/consumer_test.go": "b62ad30f5912c50e1f6bb1737fd75430ead113989990acb6dcdc93f5d88de0af",
"contract/policy_test.go": "d1ea81c923dda58ae75323cc1eb9773517e63180153dbdf19eb35a634c9890db",
"app/body.go": "4064b96fc38c5b75ec17f0d82e0dec79242640c4d0903bf544e4fda057e5397a",
"app/router.go": "b0418e18fd546b855a4cce9ff19efc25b274d9b9c52144e12338bb5d97a5be9f",
"app/requests.go": "c4cb453e8215ed594e6755b256b4b1bbcd3b870584f4be19480d4dd1b9e0aa3d",
"app/bodyPolicy_test.go": "505afa7bcc3f70009ef2af2c455e377277db01c2b883c8e5204de0cbebf25cbb",
"app/requests_test.go": "b008b5599e1f5eea01a08f15220588bd581a2d5e98658d53f0acb273dd946137",
"app/transport_test.go": "10f086eeb77e3b11e83768bbbb0372aa68056a8c8839a18edfaf597b5acff689",
"httpx/context.go": "e3a7d3cd31bc6bddaf21c2be35887cf5c85e69ec5e6ff82178884cb0e4696ffb",
"internal/httplimits/limits.go": "98ba40ceda78fcf4b08d51b0851de851329f568fd35b0a4aedefd4b48a43e780",
"lifecycle/runtime_test.go": "ccd302f78535215bea69d7c8ad8c812c79c52cca087353358206cb6b5926d34b",
"transport/http2/server.go": "e0b7fdb3069583d042df5117b36a3fffc423ee452b0a9da162e3604fcce80b49",
"transport/http2/lifecycle_test.go": "2a298b8e466ff4145105338904ec5d9e19087c91d829d6ae5cad8c8b1ee05b15",
"transport/native/server.go": "8c2fc503bc157265838b453bfe289ec68eeb2bdaf63f6dd1e585536a8278ba0c",
"transport/native/earlyReject_test.go": "0fd598e24199beb55bead10d741d739f7361345584c4430b4a97c35d59d62774",
"examples/httpSafety/main.go": "4651b3806e7a8030101ba6a70794fcc18a50d2939fe0a59d7f80bb4a7de30f15",
"storage/upload.go": "9ff2930ba389163acc8063b51107c09a60290609ba384de8004c498ce09f20a1",
"storage/read_test.go": "9b11ab9640e98c1c9fa7ce1e2587df217e874e3a6a6e27a0dd0ade6fbf3d5e1a",
"storage/signedUpload.go": "a8d31bcbbad64796f04611e69271e94d917c9bcd5aeabd7a97ea155b0093c814",
"storage/multipart_test.go": "4053f680f7795db2a6b4aa23590b3669eec7a609dd92c4a1e20891a5299274ee",
"storage/multipart.go": "3b718ebe6da681c89a3f797892c88e7acb15c295df2376d64ac35da0976ed69d",
"storage/signed_test.go": "eaffd40894018814e976477f96cf0fbaecfa1987caad78830d9fc8e96995882f",
"storage/upload_test.go": "12b60419807ed64b89e30861674e19abee7b0b7bb0e8ec9ac5b3aa2e0710a1da",
"storage/storage.go": "cb670872f01cf1d7b4ebf21cec9fc6f52ad93bfdf523b2f28bbb8d9c7fe39a6c",
"storage/signed.go": "ae4b144137627be35718c40743ebc2f063ec667c24b6d8984ccfcca034a73787",
"storage/read.go": "92787c74ae024822f82b5b84bffc9f8a355f1f2565b99ca7fbefe49157ef4851",
"storage/s3/upload.go": "3ce0b3626249ffe7a041606d6a9481743af8a0d4868fadea20ed3ed44ee0c765",
"storage/s3/read_test.go": "9b64fda5c83ba41508ec23cf41aeeb9826b677caaa851dcbeb04abe1f43eed65",
"storage/s3/config.go": "626ec6a2c0b06c95ee7eb7aa0832b190cfcdeb8a127b6b89e99204040bc3b041",
"storage/s3/upload_test.go": "a41fdbd100d9c79a3606c5942f98017afe26d841b251d96e979df87d0af93867",
"storage/s3/read.go": "319b57f2ecf435b71085c7cdcafd50a1e30caa5f357f83d4db3cdcef085792b6",
"storage/s3/s3_test.go": "9ef284926ec9936ddfec8c52a9bf04fc7b4be55c43a535f6c5a428d1535f6fec",
"storage/s3/s3.go": "016bef5948e97f3d043d2e858716f1c02d9f27c4e02802bdc1be3860116f4b6c",
"storage/local/read_test.go": "ef1f7feb0887ea4b1f30304dfaf542fe9788b6e17856c942f1cd98448656194f",
"storage/local/local_test.go": "3a2241840968df8e37fd67169c3ef708228449e81f65aff908c8b60d49434dc0",
"storage/local/local.go": "5d1ca75d0d2ca87981eef84a60bf98d0feefa2a9594940d6bd231877f4f8ec02",
"storage/oss/upload.go": "556129611f9e5549cac8a36b4026bfb19c112a295b55192579f9ac116e20626f",
"storage/oss/read_test.go": "a1c273cfedd2f3ee31ce407a7ddd6fd5ad02c65ad632a91bc7f3ead3712b5bbd",
"storage/oss/oss_test.go": "555a954010b1218804dc7951fc75e42eae24eba9a99c0871f5ba787f3fc87d45",
"storage/oss/upload_test.go": "08eb0ed157f3743288d781f61d4d2b7bfe77852a8259c5e598c719d35889409e",
"storage/oss/oss.go": "4216ce9f3fe3ac5418a5b809db3cef7e99dcca72a25a2a12301f5897cda85dd5",
"storage/oss/read.go": "429a9a9f6c586a885d7cccb76c9d572d53a79df637ff128caacb2eac35b8d862",
"storage/storagetest/contract.go": "e7ad7242102437882269d4066462059d7974586b36a82d18639eca10fd932be9",
"storage/storagetest/uploads.go": "9b66f86dd94ccdf30c7d603d432bc2a55956608898e46c1274fa27d16fa1a969",
"examples/signedRead/main.go": "f921a3c5fbcbfa84291c0c95a9c2c211319a8f0537acc5ec640238f251e652e1",
"examples/signedRead/main_test.go": "6ab559c442b2aa355231efe0342b9ec3617bf97bb1f6dfd7ad304b9a452e4fc4"
}
}

View File

@@ -0,0 +1,34 @@
{
"passed": true,
"frameworkVersion": "v0.29.0-preview",
"frameworkSum": "h1:FV3AzbYWBFQ7qnF3T2FWypGv2PSecCJfcOuNa2yKzRk=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 25.55,
"checks": [
"remoteCLIInstall",
"remoteAPIModule",
"remoteGenericModule",
"explicitKinds",
"manifestInferredKinds",
"mixedFormatWithProjectSkip",
"mixedGeneration",
"mixedCheck",
"nativeGoWorkBuild",
"nativeGoWorkRaceTests",
"moduleAndWorkspaceMetadataPreserved",
"bothProjectFailuresAggregated",
"nonzeroFailureExit",
"failedCheckIsReadOnly",
"crossProjectPreflight",
"missingGeneratedOutputRestored"
],
"sourceHashes": {
"cmd/go-omni/apigen/workspace.go": "cc39732ed9a41bb2dfa14933a830a7f10e8e3b2edc2ada07c376a738c0ec77e0",
"cmd/go-omni/apigen/workspaceMixed_test.go": "119f2a2aadc2b3ee2471bed1309c6ad8e36488b258f2ab1ba5ba5bab4219643f",
"cmd/go-omni/apigen/workspaceCatalog_test.go": "372874322aece46714351f088b5741642fe4e63d95062743a575958fa58b8b39",
"cmd/go-omni/workspace.go": "b7bbd3d9d58b3987090edf7e3714d7d8396e7c332174022d918a52e16396397d",
"cmd/go-omni/workspace_test.go": "0170d18158325262765d8b3067f55ca3ce9fe994f2d030cafee3e82b13bfcd4b",
"scripts/verifyWorkspaceRelease.py": "0495dc97c68ac17a7969088cf94b1cbbfa6e53f18fb36147c64bc34edb260bca"
}
}

View File

@@ -161,7 +161,19 @@ def main():
checks += ["remoteExplicitConfigurationRaceTests", "literalAndRequiredDefaultReferences",
"remoteCheckConfigBeforeDependencies", "removedImplicitEnvironmentRules"]
# The generated starter imports no cloud storage SDK. Resolve these
# optional adapter dependencies before testing their downloaded packages;
# lazy module loading otherwise leaves their sums out of starter/go.sum.
run(["go", "get", MODULE + "/storage/oss@" + args.version,
MODULE + "/storage/s3@" + args.version], starter, env)
for package, expected in {
"storage": {"TestReadOptions", "TestReadSignatureAndExpiry", "TestReadRangeStreamingAndCache"},
"storage/local": {"TestSignedPayloadRanges"},
"storage/oss": {"TestReadOptionsNativeSignatures"},
"storage/s3": {"TestReadOptionsS3AndR2"},
"examples/signedRead": {"TestSignedReadHTTP"},
"telemetry": {"TestConfiguredHeaderPropagation", "TestConfiguredHeaderPropagationPrefersW3CAndRejectsInvalidSetup"},
"examples/telemetryHeaders": {"TestTelemetryHeadersExample"},
"copyx": {"TestCheckTypesAndEmptyCollections", "TestCheckRulesAndConverterBoundaries", "TestCopierCheckConcurrentAndOptionsIsolation"},
"logx": {"TestLoggerTimeZoneIsolation", "TestLoggerTimeZoneValidationAndDST", "TestLoggerTimeZoneConcurrentChildren"},
"cors": {"TestPolicyPreflightAndActual", "TestPolicyValidation", "TestPolicyCredentialsWildcardAndDuplicates", "TestPolicyPreservesHTTPAndSnapshot"},
@@ -181,6 +193,15 @@ def main():
events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
passed = {event.get("Test") for event in events if event.get("Action") == "pass"}
assert expected <= passed
signed_read_output = run(["go", "run", MODULE + "/examples/signedRead", "-check"], starter, env)
assert "PDF preview, Chinese filename, download, HEAD and Range passed" in signed_read_output
checks += ["remoteSignedReadOptionsAndLegacyLinks", "remoteReadSignatureTamperingAndExpiry",
"remoteLocalPayloadRangesAndHEAD", "remoteOSSNativeV1V4Signatures",
"remoteS3R2ReadOptions", "remoteSignedReadHTTPExample"]
header_output = run(["go", "run", MODULE + "/examples/telemetryHeaders"], starter, env)
assert "traceId=11111111111111111111111111111111" in header_output
checks += ["remoteConfiguredHeaderPropagation", "remoteHeaderW3CPrecedenceAndValidation",
"remoteHeaderTelemetryExample"]
checks += ["remotePreciseModuleDiagnostics", "remoteJSONSuccessStatuses", "remoteRejectedStatusBodyCleanup",
"remoteCopyTypePreflight", "remoteEmptyCollectionTypeRejection", "remoteLoggerTimeZoneIsolation",
"remoteCORSPolicyRace", "remoteCORSErrorResponses", "remoteCORSTransports", "generatedCORSConfigOnly"]
@@ -247,6 +268,10 @@ def main():
sources += ["app/body.go", "app/router.go", "app/requests.go", "app/bodyPolicy_test.go", "app/requests_test.go", "app/transport_test.go",
"httpx/context.go", "internal/httplimits/limits.go", "lifecycle/runtime_test.go", "transport/http2/server.go", "transport/http2/lifecycle_test.go",
"transport/native/server.go", "transport/native/earlyReject_test.go", "examples/httpSafety/main.go"]
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "storage").rglob("*.go")]
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "examples/signedRead").glob("*.go")]
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "telemetry").glob("*.go")]
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "examples/telemetryHeaders").glob("*.go")]
report = {"passed": True, "frameworkVersion": args.version, "frameworkSum": dependency["Sum"],
"freshModuleCache": True, "noLocalReplace": True,
"durationSeconds": round(time.monotonic() - started, 2), "checks": checks,

View File

@@ -6,12 +6,42 @@
| --- | --- | --- | --- | --- |
| 读写删除 | Store | 支持 | 支持 | 支持 |
| 临时下载 | Presigner | NewSigner | 支持 | 支持 |
| 带选项的预览/下载 | ReadPresigner | NewSigner | 支持 | 支持 |
| 限定大小/类型的直传 | UploadPresigner | NewUploadSigner | 支持 | 支持,需要配置签名 Header |
| 创建、上传、查询、完成、取消分片 | MultipartStore | 当前不提供 | 支持 | 支持 |
| 分片直传签名 | PartPresigner | 当前不提供 | 支持 | 支持,需要配置签名 Header |
没有固定业务字段、目录规则或身份模型。鉴权、对象归属、业务配额由应用定义。SDK 客户端和连接池由应用持有;适配器不会在一次调用后关闭客户端。
## 签名预览与下载
`v0.29.0-preview` 新增独立的可选接口 `ReadPresigner`,不扩充原 Store/Presigner,不要求第三方适配器修改代码。原 `PresignGet(ctx, key, ttl)` 和既有本地下载链接继续有效、默认 attachment。
```go
// signer 可以是 storage.NewSigner 返回的本地 Handler,或 S3/R2、OSS Store。
link, err := signer.PresignGetWithOptions(ctx, key, 30*time.Minute, storage.ReadOptions{
Disposition: storage.DispositionInline,
FileName: "示例报告.pdf",
ContentType: "application/pdf",
})
if err != nil { return err }
// 把 link.URL 和 link.ExpiresAt 返回给已授权的调用方。
```
TTL 仍为 1 秒到 1 小时,30 分钟无需改配置。`ReadLink` 包含 `url` 和 `expiresAt`,到期时间取自实际签名,精确到秒(不足一秒的部分舍弃);临时云凭据可能更早失效。无需额外请求 Header,客户端直接打开 URL。可通过 `storage.ReadPresigner` 做能力判断;不支持时由调用方明确处理,不应静默降级成公开地址。
`Disposition` 只接受 inline/attachment,空值为 attachment。`FileName` 是显示名,与存储 key 无关;中文使用标准 `filename*` 编码,拒绝控制字符(包括 CR/LF)、路径分隔符及超长值。`ContentType` 为空时沿用对象元数据,可显式指定合法 MIME 类型;它只控制响应,不转换或验证文件内容。PDF 应在上传时就保存 `application/pdf` 元数据。inline 请求内联显示,能否展示还取决于浏览器的格式支持与用户设置。
本地签名绑定 key、到期时间、版本及全部选项,拒绝重复/未知参数和篡改;先验签、验有效期,再读取对象或处理条件请求。Local 使用受文件内容边界约束的流,GET/HEAD 的长度不包含内部元数据;支持单段/多段 Range、206/416,以及 Last-Modified 条件请求。HEAD 返回完整对象头、无响应体,忽略 Range。自定义 Store 的 Body 实现 `io.ReadSeeker` 时复用同一行为;仅支持顺序读取时返回完整 200,不宣称支持范围读取,也不把整个对象加载到内存。
云端适配器在 OSS/S3 原生 SDK 签名前传入响应参数,禁止先签名后拼参数或替换域名。Range 由云存储处理;云端 GET 签名不能直接改成 HEAD 使用。新接口请求 `Cache-Control: private, no-store`,本地成功、错误及条件响应均保持该策略;云端响应头覆盖受平台规则约束,需检查实际 200/206 响应与代理缓存配置。
OSS 默认域名或加速域名可能强制下载。需要自定义域名时,在应用装配 SDK client 时设置已绑定桶的域名和 `WithUseCName(true)`,再传给适配器;SDK 的区域、签名版本、凭据由应用配置。例如在现有 SDK config 上调用 `WithEndpoint("https://files.example").WithUseCName(true)`。参见 [OSS 预览规则](https://help.aliyun.com/zh/oss/how-to-ensure-an-object-is-previewed-when-you-access-the-object)与 [GetObject 响应参数规则](https://www.alibabacloud.com/help/en/oss/developer-reference/getobject)。
签发权限、对象是否不可变、审计和前端链接复用由项目维护。框架不添加分享表、撤销、租户或数据库依赖;重新签发不会撤销旧链接,到期不会收回已下载内容,也不会中断已开始的传输。本地入口使用显式配置的公开 baseURL,不猜测 Host/转发头,不公开磁盘目录。应用与代理日志应隐藏查询参数。
完整离线 [预览/下载示例](../examples/signedRead/README.md),包含真实 HTTP 检查与一页 PDF。OSS V1/V4、S3/R2 SDK 签名和隔离 MinIO 有回归;OSS/R2 云账号尚未实测,部署方仍需对实际桶、域名及浏览器进行验收。
## 服务端上传限制
```go
@@ -85,7 +115,7 @@ if err != nil { return err }
request, err := signer.PresignPut(ctx, key, size, "image/png", time.Minute)
```
Handler 检查签名、有效期、单值参数、Content-Length/Content-Type,以及实际内容大小和嗅探类型,成功返回 201。本地存储只开放一个受控入口,不把磁盘目录映射成公共目录。原 `NewSigner` 继续只服务下载。请求超时、CORS、并发限制和速率限制由承载该 Handler 的服务配置。
Handler 检查签名、有效期、单值参数、Content-Length/Content-Type,以及实际内容大小和嗅探类型,成功返回 201。本地存储只开放一个受控入口,不把磁盘目录映射成公共目录。`NewSigner` 提供签名读取,不接受上传;需要上传时使用 `NewUploadSigner`。请求超时、CORS、并发限制和速率限制由承载该 Handler 的服务配置。
## 分片与续传

View File

@@ -151,14 +151,31 @@ func (s *Store) Get(ctx context.Context, key string) (storage.Object, error) {
}
info.ModifiedAt = stat.ModTime()
ok = true
return storage.Object{Info: info, Body: &body{Reader: storage.ContextReader{Context: ctx, Reader: io.LimitReader(f, info.Size)}, Closer: f}}, nil
// Expose only the payload as a seekable section. Seeks are relative to the
// object, never to our private metadata header or other disk bytes.
return storage.Object{Info: info, Body: &body{section: io.NewSectionReader(f, int64(length)+4, info.Size), file: f, ctx: ctx}}, nil
}
type body struct {
io.Reader
io.Closer
section *io.SectionReader
file *os.File
ctx context.Context
}
func (b *body) Read(p []byte) (int, error) {
if err := b.ctx.Err(); err != nil {
return 0, err
}
return b.section.Read(p)
}
func (b *body) Seek(offset int64, whence int) (int64, error) {
if err := b.ctx.Err(); err != nil {
return 0, err
}
return b.section.Seek(offset, whence)
}
func (b *body) Close() error { return b.file.Close() }
func (s *Store) Delete(ctx context.Context, key string) error {
if err := storage.Check(ctx, key); err != nil {
return err

125
storage/local/read_test.go Normal file
View File

@@ -0,0 +1,125 @@
package local
import (
"context"
"io"
"mime"
"mime/multipart"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/storage"
)
func TestSignedPayloadRanges(t *testing.T) {
store, err := New(t.TempDir(), 1<<20)
if err != nil {
t.Fatal(err)
}
data := "%PDF-1.4\n" + strings.Repeat("0123456789", 100) + "\n%%EOF\n"
ctx := context.Background()
if _, err = store.Put(ctx, "private/document", strings.NewReader(data), int64(len(data)), "application/pdf"); err != nil {
t.Fatal(err)
}
signer, _ := storage.NewSigner(store, "https://files.example/read", []byte(strings.Repeat("s", 32)))
link, err := signer.PresignGetWithOptions(ctx, "private/document", 30*time.Minute, storage.ReadOptions{Disposition: storage.DispositionInline, FileName: "示例.pdf"})
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
method, span string
status int
want string
}{
{"GET", "", 200, data}, {"HEAD", "", 200, ""}, {"HEAD", "bytes=0-3", 200, ""},
{"GET", "bytes=0-7", 206, data[:8]}, {"GET", "bytes=20-", 206, data[20:]}, {"GET", "bytes=-6", 206, data[len(data)-6:]},
{"GET", "bytes=1000-999999", 206, data[1000:]}, {"GET", "bytes=999999-", 416, ""}, {"GET", "bytes=bad", 416, ""},
} {
t.Run(tc.method+tc.span, func(t *testing.T) {
r := httptest.NewRequest(tc.method, link.URL, nil)
if tc.span != "" {
r.Header.Set("Range", tc.span)
}
w := httptest.NewRecorder()
signer.ServeHTTP(w, r)
if w.Code != tc.status {
t.Fatal(w.Code)
}
if tc.status != 416 && w.Body.String() != tc.want {
t.Fatal("wrong payload slice")
}
if tc.status == 206 && (w.Header().Get("Content-Length") != strconv.Itoa(len(tc.want)) || !strings.HasSuffix(w.Header().Get("Content-Range"), "/"+strconv.Itoa(len(data)))) {
t.Fatal(w.Header())
}
if tc.status == 200 && w.Header().Get("Content-Length") != strconv.Itoa(len(data)) {
t.Fatal("metadata included in length")
}
if tc.span == "bytes=999999-" && w.Header().Get("Content-Range") != "bytes */"+strconv.Itoa(len(data)) {
t.Fatal(w.Header())
}
if w.Header().Get("Cache-Control") != "private, no-store" {
t.Fatal("cache policy")
}
if tc.status != 416 {
kind, params, err := mime.ParseMediaType(w.Header().Get("Content-Disposition"))
if err != nil || kind != "inline" || params["filename"] != "示例.pdf" || w.Header().Get("Content-Type") != "application/pdf" {
t.Fatal(w.Header(), err)
}
}
})
}
r := httptest.NewRequest("GET", link.URL, nil)
r.Header.Set("Range", "bytes=0-3,10-13")
w := httptest.NewRecorder()
signer.ServeHTTP(w, r)
kind, params, err := mime.ParseMediaType(w.Header().Get("Content-Type"))
if err != nil || w.Code != 206 || kind != "multipart/byteranges" {
t.Fatal(w.Header(), err)
}
reader := multipart.NewReader(w.Body, params["boundary"])
for _, want := range []string{data[:4], data[10:14]} {
p, err := reader.NextPart()
if err != nil {
t.Fatal(err)
}
got, err := io.ReadAll(p)
if err != nil || string(got) != want {
t.Fatal("multipart bytes", err)
}
}
if _, err := reader.NextPart(); err != io.EOF {
t.Fatal(err)
}
// Random access cannot expose the managed header and still observes context.
ctx, cancel := context.WithCancel(ctx)
defer cancel()
object, err := store.Get(ctx, "private/document")
if err != nil {
t.Fatal(err)
}
defer object.Body.Close()
seeker := object.Body.(io.ReadSeeker)
if size, err := seeker.Seek(0, io.SeekEnd); err != nil || size != int64(len(data)) {
t.Fatal(size, err)
}
if _, err := seeker.Seek(-1, io.SeekStart); err == nil {
t.Fatal("seek into header")
}
if _, err := seeker.Seek(0, io.SeekStart); err != nil {
t.Fatal(err)
}
prefix := make([]byte, 8)
if _, err := io.ReadFull(seeker, prefix); err != nil || string(prefix) != data[:8] {
t.Fatal("metadata exposed", err)
}
cancel()
if _, err := seeker.Seek(0, io.SeekStart); err != context.Canceled {
t.Fatal(err)
}
if _, err := seeker.Read(prefix); err != context.Canceled {
t.Fatal(err)
}
}

59
storage/oss/read.go Normal file
View File

@@ -0,0 +1,59 @@
package oss
import (
"context"
"errors"
"net/url"
"strconv"
"time"
"git.yuntuo.hk/k2safe/go-omni/storage"
sdk "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
)
func (s *Store) PresignGetWithOptions(ctx context.Context, key string, ttl time.Duration, options storage.ReadOptions) (storage.ReadLink, error) {
if err := storage.Check(ctx, key); err != nil {
return storage.ReadLink{}, err
}
if ttl < time.Second || ttl > time.Hour {
return storage.ReadLink{}, errors.New("storage/oss: TTL must be 1s..1h")
}
headers, err := options.ResponseHeaders()
if err != nil {
return storage.ReadLink{}, err
}
request := &sdk.GetObjectRequest{Bucket: sdk.Ptr(s.bucket), Key: sdk.Ptr(key), ResponseContentDisposition: sdk.Ptr(headers.Get("Content-Disposition")), ResponseCacheControl: sdk.Ptr(headers.Get("Cache-Control"))}
if contentType := headers.Get("Content-Type"); contentType != "" {
request.ResponseContentType = sdk.Ptr(contentType)
}
r, err := s.client.Presign(ctx, request, sdk.PresignExpires(ttl))
if err != nil {
return storage.ReadLink{}, err
}
if len(r.SignedHeaders) != 0 {
return storage.ReadLink{}, errors.New("storage/oss: direct GET requires a signature without additional request headers")
}
u, err := url.Parse(r.URL)
if err != nil {
return storage.ReadLink{}, errors.New("storage/oss: invalid signed URL")
}
q := u.Query()
var expires time.Time
if q.Get("x-oss-signature") != "" {
start, e := time.Parse("20060102T150405Z", q.Get("x-oss-date"))
seconds, e2 := strconv.ParseInt(q.Get("x-oss-expires"), 10, 64)
if e != nil || e2 != nil || seconds < 1 || seconds > 3600 {
return storage.ReadLink{}, errors.New("storage/oss: invalid signed expiry")
}
expires = start.Add(time.Duration(seconds) * time.Second)
} else {
seconds, e := strconv.ParseInt(q.Get("Expires"), 10, 64)
if e != nil || q.Get("Signature") == "" {
return storage.ReadLink{}, errors.New("storage/oss: missing signed expiry")
}
expires = time.Unix(seconds, 0).UTC()
}
return storage.ReadLink{URL: r.URL, ExpiresAt: expires}, nil
}
var _ storage.ReadPresigner = (*Store)(nil)

140
storage/oss/read_test.go Normal file
View File

@@ -0,0 +1,140 @@
package oss
import (
"context"
"crypto/hmac"
"crypto/sha1"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"mime"
"net/url"
"sort"
"strconv"
"strings"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/storage"
sdk "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/signer"
)
// Recompute signatures independently from the SDK using fixed test credentials.
// This catches response parameters appended after signing, not just their presence.
func validReadSignature(u *url.URL, version sdk.SignatureVersionType) bool {
q := u.Query()
if version == sdk.SignatureVersionV1 {
var keys []string
for key := range q {
if strings.HasPrefix(key, "response-") {
keys = append(keys, key)
}
}
sort.Strings(keys)
var params []string
for _, key := range keys {
params = append(params, key+"="+q.Get(key))
}
message := "GET\n\n\n" + q.Get("Expires") + "\n/testbucket/folder/file.pdf?" + strings.Join(params, "&")
mac := hmac.New(sha1.New, []byte("fixtureSecret"))
mac.Write([]byte(message))
return hmac.Equal([]byte(q.Get("Signature")), []byte(base64.StdEncoding.EncodeToString(mac.Sum(nil))))
}
signature := q.Get("x-oss-signature")
q.Del("x-oss-signature")
canonical := "GET\n/testbucket/folder/file.pdf\n" + strings.ReplaceAll(q.Encode(), "+", "%20") + "\n\n\nUNSIGNED-PAYLOAD"
hash := sha256.Sum256([]byte(canonical))
_, scope, _ := strings.Cut(q.Get("x-oss-credential"), "/")
message := "OSS4-HMAC-SHA256\n" + q.Get("x-oss-date") + "\n" + scope + "\n" + hex.EncodeToString(hash[:])
key := []byte("aliyun_v4fixtureSecret")
for _, part := range strings.Split(scope, "/") {
mac := hmac.New(sha256.New, key)
mac.Write([]byte(part))
key = mac.Sum(nil)
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(message))
return hmac.Equal([]byte(signature), []byte(hex.EncodeToString(mac.Sum(nil))))
}
func TestReadOptionsNativeSignatures(t *testing.T) {
for name, version := range map[string]sdk.SignatureVersionType{"v1": sdk.SignatureVersionV1, "v4": sdk.SignatureVersionV4} {
t.Run(name, func(t *testing.T) {
cfg := sdk.LoadDefaultConfig().WithRegion("cn-hangzhou").WithEndpoint("https://files.example").WithUseCName(true).WithSignatureVersion(version).WithCredentialsProvider(credentials.NewStaticCredentialsProvider("fixtureAccess", "fixtureSecret"))
store, err := New(sdk.NewClient(cfg), "testbucket", 64)
if err != nil {
t.Fatal(err)
}
for _, options := range []storage.ReadOptions{{}, {Disposition: storage.DispositionInline, FileName: "报告 2026.pdf", ContentType: "application/pdf"}, {Disposition: storage.DispositionAttachment, FileName: "download.pdf"}} {
before := time.Now().UTC().Truncate(time.Second)
link, err := store.PresignGetWithOptions(context.Background(), "folder/file.pdf", 30*time.Minute, options)
if err != nil {
t.Fatal(err)
}
u, err := url.Parse(link.URL)
if err != nil {
t.Fatal(err)
}
q := u.Query()
if u.Host != "files.example" || u.Path != "/folder/file.pdf" || !validReadSignature(u, version) {
t.Fatal("SDK response overrides were not signed")
}
disposition, params, err := mime.ParseMediaType(q.Get("response-content-disposition"))
want := options.Disposition
if want == "" {
want = "attachment"
}
if err != nil || disposition != want || params["filename"] != options.FileName || q.Get("response-content-type") != options.ContentType || q.Get("response-cache-control") != "private, no-store" {
t.Fatal("wrong signed headers", err)
}
if link.ExpiresAt.Before(before.Add(30*time.Minute)) || link.ExpiresAt.After(time.Now().Add(30*time.Minute)) {
t.Fatal("expiry drift")
}
var expires time.Time
if version == sdk.SignatureVersionV4 {
start, _ := time.Parse("20060102T150405Z", q.Get("x-oss-date"))
seconds, _ := strconv.Atoi(q.Get("x-oss-expires"))
expires = start.Add(time.Duration(seconds) * time.Second)
} else {
seconds, _ := strconv.ParseInt(q.Get("Expires"), 10, 64)
expires = time.Unix(seconds, 0)
}
if !expires.Equal(link.ExpiresAt) {
t.Fatal("result does not match signature expiry")
}
for _, name := range []string{"response-content-disposition", "response-content-type", "response-cache-control"} {
changed := *u
query := changed.Query()
query.Set(name, "changed")
changed.RawQuery = query.Encode()
if validReadSignature(&changed, version) {
t.Fatal("unsigned override", name)
}
}
}
for _, ttl := range []time.Duration{0, time.Second - time.Nanosecond, time.Hour + time.Nanosecond} {
if _, err := store.PresignGetWithOptions(context.Background(), "file", ttl, storage.ReadOptions{}); err == nil {
t.Fatal("TTL accepted")
}
}
for _, options := range []storage.ReadOptions{{FileName: "x\r\nY: z"}, {ContentType: "text/plain\r\nY: z"}, {Disposition: "bad"}} {
if _, err := store.PresignGetWithOptions(context.Background(), "file", time.Minute, options); err == nil {
t.Fatal("invalid header accepted")
}
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := store.PresignGetWithOptions(ctx, "file", time.Minute, storage.ReadOptions{}); err != context.Canceled {
t.Fatal(err)
}
})
}
// A caller-supplied unsigned SDK configuration cannot masquerade as a link.
client := sdk.NewClient(sdk.LoadDefaultConfig().WithRegion("cn-hangzhou").WithEndpoint("https://files.example").WithCredentialsProvider(credentials.NewStaticCredentialsProvider("fixtureAccess", "fixtureSecret")), func(o *sdk.Options) { o.Signer = &signer.NopSigner{} })
store, _ := New(client, "testbucket", 64)
if _, err := store.PresignGetWithOptions(context.Background(), "file", time.Minute, storage.ReadOptions{}); err == nil {
t.Fatal("unsigned URL accepted")
}
}

72
storage/read.go Normal file
View File

@@ -0,0 +1,72 @@
package storage
import (
"context"
"errors"
"mime"
"net/http"
"strings"
"time"
"unicode"
"unicode/utf8"
)
const (
DispositionAttachment = "attachment"
DispositionInline = "inline"
)
// ReadOptions controls the response to one signed GET. Empty Disposition means
// attachment; empty ContentType preserves object metadata. FileName is a display
// name, not an object key or filesystem path. All options are signed.
type ReadOptions struct {
Disposition string
FileName string
ContentType string
}
// ReadLink grants GET access without additional request headers. ExpiresAt is
// the expiry encoded in the signature, at second precision. Provider credentials
// may expire earlier. Do not log the URL. Issuing another link does not revoke it.
type ReadLink struct {
URL string `json:"url"`
ExpiresAt time.Time `json:"expiresAt"`
}
// ReadPresigner is optional; existing Store and Presigner implementations do not
// need to change. Cloud GET signatures do not also authorize HEAD requests.
type ReadPresigner interface {
PresignGetWithOptions(context.Context, string, time.Duration, ReadOptions) (ReadLink, error)
}
// ResponseHeaders validates options and returns a fresh set of response headers
// for provider adapters to include BEFORE signing, requesting private, no-store.
func (o ReadOptions) ResponseHeaders() (http.Header, error) {
disposition := o.Disposition
if disposition == "" {
disposition = DispositionAttachment
}
if disposition != DispositionAttachment && disposition != DispositionInline {
return nil, errors.New("storage: disposition must be inline or attachment")
}
if len(o.FileName) > 1024 || !utf8.ValidString(o.FileName) || strings.IndexFunc(o.FileName, unicode.IsControl) >= 0 || strings.ContainsAny(o.FileName, `/\`) || o.FileName == "." || o.FileName == ".." {
return nil, errors.New("storage: invalid display filename")
}
params := map[string]string{}
if o.FileName != "" {
params["filename"] = o.FileName
}
h := http.Header{"Content-Disposition": {mime.FormatMediaType(disposition, params)}, "Cache-Control": {"private, no-store"}}
if o.ContentType != "" {
if !utf8.ValidString(o.ContentType) || strings.IndexFunc(o.ContentType, unicode.IsControl) >= 0 {
return nil, ErrContentType
}
kind, err := MediaType(o.ContentType)
if err != nil {
return nil, err
}
_, params, _ := mime.ParseMediaType(o.ContentType)
h.Set("Content-Type", mime.FormatMediaType(kind, params))
}
return h, nil
}

219
storage/read_test.go Normal file
View File

@@ -0,0 +1,219 @@
package storage
import (
"context"
"io"
"mime"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
func TestReadOptions(t *testing.T) {
for _, name := range []string{"", "report.pdf", "报告 2026.pdf", `a";b.pdf`, "100%.pdf"} {
h, err := (ReadOptions{Disposition: DispositionInline, FileName: name, ContentType: "Application/PDF"}).ResponseHeaders()
if err != nil {
t.Fatal(err)
}
disposition, params, err := mime.ParseMediaType(h.Get("Content-Disposition"))
if err != nil || disposition != "inline" || params["filename"] != name || h.Get("Content-Type") != "application/pdf" || h.Get("Cache-Control") != "private, no-store" {
t.Fatal("invalid read headers", h, err)
}
}
h, err := (ReadOptions{}).ResponseHeaders()
if err != nil || h.Get("Content-Disposition") != "attachment" || h.Get("Content-Type") != "" {
t.Fatal(h, err)
}
for _, options := range []ReadOptions{
{Disposition: "form-data"}, {Disposition: "Inline"}, {Disposition: "inline\r\nX: y"},
{FileName: "x\r\nX: y"}, {FileName: "a\x00b"}, {FileName: "a\tb"}, {FileName: "bad\xff"}, {FileName: "a/b"}, {FileName: `a\b`}, {FileName: ".."}, {FileName: strings.Repeat("a", 1025)},
{ContentType: "bad"}, {ContentType: "image/*"}, {ContentType: "application/pdf\r\nX: y"}, {ContentType: "application/pdf\x00"}, {ContentType: strings.Repeat("x", 257)},
} {
if _, err := options.ResponseHeaders(); err == nil {
t.Fatal("invalid options accepted", options)
}
}
}
type readTestStore struct {
calls, bytes int
streaming bool
}
func (*readTestStore) Put(context.Context, string, io.Reader, int64, string) (Info, error) {
panic("unused")
}
func (*readTestStore) Delete(context.Context, string) error { panic("unused") }
func (s *readTestStore) Get(context.Context, string) (Object, error) {
s.calls++
body := &readTestBody{Reader: strings.NewReader("0123456789"), store: s}
var stream io.ReadCloser = body
if s.streaming {
stream = struct {
io.Reader
io.Closer
}{body, body}
}
return Object{Info: Info{Size: 10, ContentType: "application/pdf", ModifiedAt: time.Unix(1000, 0)}, Body: stream}, nil
}
type readTestBody struct {
*strings.Reader
store *readTestStore
}
func (b *readTestBody) Read(p []byte) (int, error) {
n, err := b.Reader.Read(p)
b.store.bytes += n
return n, err
}
func (*readTestBody) Close() error { return nil }
func TestReadSignatureAndExpiry(t *testing.T) {
store := &readTestStore{}
s, _ := NewSigner(store, "https://files.example/read", []byte(strings.Repeat("s", 32)))
now := time.Unix(1800000000, 0).UTC()
s.now = func() time.Time { return now }
link, err := s.PresignGetWithOptions(context.Background(), "folder/file", 30*time.Minute, ReadOptions{Disposition: DispositionInline, FileName: "报告.pdf", ContentType: "application/pdf"})
if err != nil || !link.ExpiresAt.Equal(now.Add(30*time.Minute)) {
t.Fatal("incorrect expiry", err)
}
serve := func(method, address string, headers http.Header) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, address, nil)
if headers != nil {
r.Header = headers
}
w := httptest.NewRecorder()
s.ServeHTTP(w, r)
return w
}
for _, method := range []string{"GET", "HEAD"} {
w := serve(method, link.URL, nil)
if w.Code != 200 || w.Header().Get("Content-Type") != "application/pdf" || w.Header().Get("Cache-Control") != "private, no-store" {
t.Fatal(w)
}
if method == "HEAD" && w.Body.Len() != 0 {
t.Fatal("HEAD body")
}
}
old, err := s.PresignGet(context.Background(), "folder/file", 30*time.Minute)
if err != nil {
t.Fatal(err)
}
if w := serve("GET", old.String(), nil); w.Code != 200 || w.Header().Get("Content-Disposition") != "attachment" {
t.Fatal("legacy download changed")
}
mutations := []func(url.Values){
func(q url.Values) { q.Set("key", "other") }, func(q url.Values) { q.Set("expires", "1800001900") }, func(q url.Values) { q.Set("signature", "bad") },
func(q url.Values) { q.Set("filename", "other.pdf") }, func(q url.Values) { q.Set("disposition", "attachment") }, func(q url.Values) { q.Set("contentType", "text/html") },
func(q url.Values) { q.Set("v", "3") }, func(q url.Values) { q.Del("v") }, func(q url.Values) {
for _, k := range []string{"v", "filename", "disposition", "contentType"} {
q.Del(k)
}
},
func(q url.Values) { q.Set("extra", "x") }, func(q url.Values) { q.Set("filename", "evil\r\nX: yes") },
}
for _, key := range []string{"key", "expires", "signature", "v", "disposition", "filename", "contentType"} {
key := key
mutations = append(mutations, func(q url.Values) { q.Add(key, q.Get(key)) })
}
for _, mutate := range mutations {
u, _ := url.Parse(link.URL)
q := u.Query()
mutate(q)
u.RawQuery = q.Encode()
before := store.calls
if w := serve("GET", u.String(), http.Header{"Range": {"bytes=0-1"}}); w.Code != 403 || store.calls != before {
t.Fatal("tampered link reached storage")
}
}
u := *old
q := u.Query()
q.Set("disposition", "inline")
u.RawQuery = q.Encode()
if serve("GET", u.String(), nil).Code != 403 {
t.Fatal("legacy download upgraded by query")
}
if serve("GET", link.URL+"&bad=%ZZ", nil).Code != 403 {
t.Fatal("malformed query accepted")
}
before := store.calls
now = link.ExpiresAt.Add(-time.Nanosecond)
if serve("GET", link.URL, nil).Code != 200 {
t.Fatal("early expiration")
}
before = store.calls
now = link.ExpiresAt
for _, method := range []string{"GET", "HEAD"} {
w := serve(method, link.URL, http.Header{"Range": {"bytes=0-1"}, "If-Modified-Since": {"Fri, 01 Jan 2100 00:00:00 GMT"}})
if w.Code != 403 || store.calls != before || w.Header().Get("Cache-Control") != "private, no-store" {
t.Fatal("expired link reached storage or cache")
}
if method == "HEAD" && w.Body.Len() != 0 {
t.Fatal("HEAD error has body")
}
}
for _, ttl := range []time.Duration{0, time.Second - time.Nanosecond, time.Hour + time.Nanosecond} {
if _, err := s.PresignGetWithOptions(context.Background(), "key", ttl, ReadOptions{}); err == nil {
t.Fatal("invalid TTL")
}
}
for _, ttl := range []time.Duration{time.Second, time.Hour} {
if _, err := s.PresignGetWithOptions(context.Background(), "key", ttl, ReadOptions{}); err != nil {
t.Fatal(err)
}
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := s.PresignGetWithOptions(ctx, "key", time.Minute, ReadOptions{}); err != context.Canceled {
t.Fatal(err)
}
}
func TestReadRangeStreamingAndCache(t *testing.T) {
for _, streaming := range []bool{false, true} {
store := &readTestStore{streaming: streaming}
s, _ := NewSigner(store, "https://files.example/read", []byte(strings.Repeat("s", 32)))
link, _ := s.PresignGetWithOptions(context.Background(), "key", 30*time.Minute, ReadOptions{})
for _, method := range []string{"GET", "HEAD"} {
before := store.bytes
r := httptest.NewRequest(method, link.URL, nil)
r.Header.Set("Range", "bytes=2-4")
w := httptest.NewRecorder()
s.ServeHTTP(w, r)
if method == "HEAD" {
if w.Code != 200 || w.Body.Len() != 0 || store.bytes != before {
t.Fatal("HEAD read body", w)
}
} else if streaming {
if w.Code != 200 || w.Body.String() != "0123456789" || w.Header().Get("Accept-Ranges") != "" {
t.Fatal("stream fallback", w)
}
} else {
if w.Code != 206 || w.Body.String() != "234" || store.bytes-before != 3 {
t.Fatal("range buffered full body", w)
}
}
if w.Header().Get("Cache-Control") != "private, no-store" {
t.Fatal("missing cache policy")
}
}
if !streaming {
for _, h := range []http.Header{{"Range": {"bytes=99-100"}}, {"If-Modified-Since": {"Fri, 01 Jan 2100 00:00:00 GMT"}}} {
r := httptest.NewRequest("GET", link.URL, nil)
r.Header = h
w := httptest.NewRecorder()
s.ServeHTTP(w, r)
if w.Code != 416 && w.Code != 304 {
t.Fatal(w.Code)
}
if w.Header().Get("Cache-Control") != "private, no-store" {
t.Fatal("cache policy lost on error/conditional")
}
}
}
}
}

53
storage/s3/read.go Normal file
View File

@@ -0,0 +1,53 @@
package s3
import (
"context"
"errors"
"net/url"
"strconv"
"time"
"git.yuntuo.hk/k2safe/go-omni/storage"
)
func (s *Store) PresignGetWithOptions(ctx context.Context, key string, ttl time.Duration, options storage.ReadOptions) (storage.ReadLink, error) {
if err := storage.Check(ctx, key); err != nil {
return storage.ReadLink{}, err
}
if ttl < time.Second || ttl > time.Hour {
return storage.ReadLink{}, errors.New("storage/s3: TTL must be 1s..1h")
}
headers, err := options.ResponseHeaders()
if err != nil {
return storage.ReadLink{}, err
}
query := url.Values{"response-content-disposition": {headers.Get("Content-Disposition")}, "response-cache-control": {headers.Get("Cache-Control")}}
if contentType := headers.Get("Content-Type"); contentType != "" {
query.Set("response-content-type", contentType)
}
u, err := s.client.PresignedGetObject(ctx, s.bucket, key, ttl, query)
if err != nil {
return storage.ReadLink{}, err
}
// Derive the exact expiry from the SDK's signed URL, not a second clock read
// around credential discovery or bucket-region lookup.
q := u.Query()
var expires time.Time
if q.Get("X-Amz-Signature") != "" {
start, e := time.Parse("20060102T150405Z", q.Get("X-Amz-Date"))
seconds, e2 := strconv.ParseInt(q.Get("X-Amz-Expires"), 10, 64)
if e != nil || e2 != nil || seconds < 1 || seconds > 3600 {
return storage.ReadLink{}, errors.New("storage/s3: invalid signed expiry")
}
expires = start.Add(time.Duration(seconds) * time.Second)
} else {
seconds, e := strconv.ParseInt(q.Get("Expires"), 10, 64)
if e != nil || q.Get("Signature") == "" {
return storage.ReadLink{}, errors.New("storage/s3: missing signed expiry")
}
expires = time.Unix(seconds, 0).UTC()
}
return storage.ReadLink{URL: u.String(), ExpiresAt: expires}, nil
}
var _ storage.ReadPresigner = (*Store)(nil)

145
storage/s3/read_test.go Normal file
View File

@@ -0,0 +1,145 @@
package s3
import (
"context"
"io"
"mime"
"net/http"
"net/url"
"strconv"
"strings"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/storage"
"github.com/minio/minio-go/v7/pkg/credentials"
)
func TestReadOptionsS3AndR2(t *testing.T) {
for _, r2 := range []bool{false, true} {
cfg := Config{Endpoint: "files.example", Bucket: "testbucket", Region: "us-east-1", Secure: true, Credentials: credentials.NewStaticV4("fixtureAccess", "fixtureSecret", "")}
if r2 {
var err error
cfg, err = R2Config(strings.Repeat("a", 32), "testbucket", cfg.Credentials)
if err != nil {
t.Fatal(err)
}
}
store, err := Open(cfg)
if err != nil {
t.Fatal(err)
}
for _, options := range []storage.ReadOptions{{}, {Disposition: storage.DispositionInline, FileName: "报告.pdf", ContentType: "application/pdf"}} {
link, err := store.PresignGetWithOptions(context.Background(), "folder/file.pdf", 30*time.Minute, options)
if err != nil {
t.Fatal(err)
}
u, _ := url.Parse(link.URL)
q := u.Query()
kind, params, err := mime.ParseMediaType(q.Get("response-content-disposition"))
want := options.Disposition
if want == "" {
want = "attachment"
}
if err != nil || kind != want || params["filename"] != options.FileName || q.Get("response-content-type") != options.ContentType || q.Get("response-cache-control") != "private, no-store" || q.Get("X-Amz-Signature") == "" {
t.Fatal("missing response parameters", err)
}
if u.Host != cfg.Endpoint || q.Get("X-Amz-Expires") != "1800" {
t.Fatal("wrong endpoint/TTL")
}
start, err := time.Parse("20060102T150405Z", q.Get("X-Amz-Date"))
if err != nil || !link.ExpiresAt.Equal(start.Add(30*time.Minute)) {
t.Fatal("expiry drift", err)
}
}
for _, ttl := range []time.Duration{0, time.Hour + time.Nanosecond} {
if _, err := store.PresignGetWithOptions(context.Background(), "file", ttl, storage.ReadOptions{}); err == nil {
t.Fatal("invalid TTL")
}
}
if _, err := store.PresignGetWithOptions(context.Background(), "file", time.Minute, storage.ReadOptions{FileName: "x\r\nY: z"}); err == nil {
t.Fatal("invalid filename")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := store.PresignGetWithOptions(ctx, "file", time.Minute, storage.ReadOptions{}); err != context.Canceled {
t.Fatal(err)
}
}
}
// Called inside the existing isolated MinIO integration. The actual provider
// verifies signatures and rejects tampered response parameters.
func checkReadOptionsIntegration(t *testing.T, store *Store) {
t.Helper()
ctx := context.Background()
data := "%PDF-1.4\nread-test\n%%EOF"
_, err := store.Put(ctx, "preview.pdf", strings.NewReader(data), int64(len(data)), "application/pdf")
if err != nil {
t.Fatal(err)
}
defer store.Delete(ctx, "preview.pdf")
for _, kind := range []string{storage.DispositionInline, storage.DispositionAttachment} {
link, err := store.PresignGetWithOptions(ctx, "preview.pdf", 30*time.Minute, storage.ReadOptions{Disposition: kind, FileName: "示例.pdf", ContentType: "application/pdf"})
if err != nil {
t.Fatal(err)
}
client := &http.Client{Timeout: 5 * time.Second}
for _, span := range []string{"", "bytes=0-7", "bytes=999-"} {
req, _ := http.NewRequest("GET", link.URL, nil)
if span != "" {
req.Header.Set("Range", span)
}
r, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(r.Body)
r.Body.Close()
if err != nil {
t.Fatal(err)
}
want := 200
if span == "bytes=0-7" {
want = 206
}
if span == "bytes=999-" {
want = 416
}
if r.StatusCode != want {
t.Fatal("status", r.StatusCode)
}
if want == 416 {
continue
}
disposition, params, err := mime.ParseMediaType(r.Header.Get("Content-Disposition"))
if err != nil || disposition != kind || params["filename"] != "示例.pdf" || r.Header.Get("Content-Type") != "application/pdf" || r.Header.Get("Cache-Control") != "private, no-store" {
t.Fatal("provider headers", r.Header, err)
}
value := data
if want == 206 {
value = data[:8]
}
if string(body) != value || r.Header.Get("Content-Length") != strconv.Itoa(len(value)) {
t.Fatal("provider body")
}
}
for _, name := range []string{"response-content-disposition", "response-content-type", "response-cache-control", "X-Amz-Expires"} {
u, _ := url.Parse(link.URL)
q := u.Query()
q.Set(name, "changed")
if name == "X-Amz-Expires" {
q.Set(name, "1801")
}
u.RawQuery = q.Encode()
r, err := client.Get(u.String())
if err != nil {
t.Fatal(err)
}
r.Body.Close()
if r.StatusCode != 403 {
t.Fatal("tampered parameter", name, r.StatusCode)
}
}
}
}

View File

@@ -84,7 +84,9 @@ func TestS3Integration(t *testing.T) {
if !ready {
t.Fatal("isolated S3 server did not start")
}
client, err := minio.New(address, &minio.Options{Creds: credentials.NewStaticV4("goOmniTest", "goOmniTestSecret", ""), Region: "us-east-1"})
// The contract deliberately sends malformed bodies. Avoid retry backoff
// exhausting its deadline; these are expected failures, not network retries.
client, err := minio.New(address, &minio.Options{Creds: credentials.NewStaticV4("goOmniTest", "goOmniTestSecret", ""), Region: "us-east-1", MaxRetries: 1})
if err != nil {
t.Fatal(err)
}
@@ -114,6 +116,7 @@ func TestS3Integration(t *testing.T) {
}
}
s, _ := New(client, bucket, 64)
checkReadOptionsIntegration(t, s)
storagetest.Run(t, s)
s.Put(ctx, "signed", strings.NewReader("hello"), 5, "text/plain")
defer s.Delete(context.Background(), "signed")

View File

@@ -49,30 +49,88 @@ func (s *Signer) PresignGet(ctx context.Context, key string, ttl time.Duration)
return &u, nil
}
// ServeHTTP serves signed downloads as attachments. Issue links only after
func (s *Signer) readSignature(q url.Values) string {
values := make(url.Values, len(q))
for key, value := range q {
if key != "signature" {
values[key] = value
}
}
mac := hmac.New(sha256.New, s.secret)
io.WriteString(mac, "GET-OPTIONS-v2\n"+values.Encode())
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func (s *Signer) PresignGetWithOptions(ctx context.Context, key string, ttl time.Duration, options ReadOptions) (ReadLink, error) {
if err := Check(ctx, key); err != nil {
return ReadLink{}, err
}
if ttl < time.Second || ttl > time.Hour {
return ReadLink{}, errors.New("storage: TTL must be 1s..1h")
}
headers, err := options.ResponseHeaders()
if err != nil {
return ReadLink{}, err
}
if options.Disposition == "" {
options.Disposition = DispositionAttachment
}
expires := s.now().Add(ttl).UTC().Truncate(time.Second)
q := url.Values{"v": {"2"}, "key": {key}, "expires": {strconv.FormatInt(expires.Unix(), 10)}, "disposition": {options.Disposition}, "filename": {options.FileName}, "contentType": {headers.Get("Content-Type")}}
q.Set("signature", s.readSignature(q))
u := *s.base
u.RawQuery = q.Encode()
return ReadLink{URL: u.String(), ExpiresAt: expires}, nil
}
// ServeHTTP serves signed GET/HEAD requests, with optional preview headers.
// Seekable object bodies support Range and conditional reads; other stores keep
// streaming full responses. No full-object buffer is used. Issue links only after
// authorization; possession of an unexpired URL grants access to its object.
func (s *Signer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPut && s.uploadPolicy != nil {
s.serveUpload(w, r)
return
}
w = &readResponseWriter{ResponseWriter: w, head: r.Method == http.MethodHead}
if r.Method != "GET" && r.Method != "HEAD" {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "method not allowed", 405)
return
}
q, err := url.ParseQuery(r.URL.RawQuery)
if err != nil || len(q) != 3 || len(q["key"]) != 1 || len(q["expires"]) != 1 || len(q["signature"]) != 1 {
keys := []string{"key", "expires", "signature"}
if q.Get("v") == "2" {
keys = append(keys, "v", "disposition", "filename", "contentType")
}
valid := err == nil && len(q) == len(keys)
for _, key := range keys {
valid = valid && len(q[key]) == 1
}
if !valid {
http.Error(w, "invalid link", 403)
return
}
key, expiry := q.Get("key"), q.Get("expires")
expires, err := strconv.ParseInt(expiry, 10, 64)
now := s.now().Unix()
if err != nil || expires <= now || expires > now+3600 || Check(r.Context(), key) != nil || !hmac.Equal([]byte(q.Get("signature")), []byte(s.signature(key, expiry))) {
expected := s.signature(key, expiry)
if q.Get("v") == "2" {
expected = s.readSignature(q)
}
if err != nil || expires <= now || expires > now+3600 || Check(r.Context(), key) != nil || !hmac.Equal([]byte(q.Get("signature")), []byte(expected)) {
http.Error(w, "invalid or expired link", 403)
return
}
options := ReadOptions{}
if q.Get("v") == "2" {
options = ReadOptions{Disposition: q.Get("disposition"), FileName: q.Get("filename"), ContentType: q.Get("contentType")}
}
headers, err := options.ResponseHeaders()
if err != nil {
http.Error(w, "invalid link", 403)
return
}
object, err := s.store.Get(r.Context(), key)
if err != nil {
status := 500
@@ -82,17 +140,66 @@ func (s *Signer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.Error(w, "object unavailable", status)
return
}
if object.Body == nil {
http.Error(w, "object unavailable", 500)
return
}
defer object.Body.Close()
if object.Size < 0 {
http.Error(w, "object unavailable", 500)
return
}
contentType := object.ContentType
if options.ContentType != "" {
contentType = headers.Get("Content-Type")
}
if contentType == "" {
contentType = "application/octet-stream"
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", "attachment")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Cache-Control", "private, no-store")
w.Header().Set("Content-Disposition", headers.Get("Content-Disposition"))
if body, ok := object.Body.(io.ReadSeeker); ok {
// Range describes a GET representation. HEAD reports full-object headers.
if r.Method == http.MethodHead {
r = r.Clone(r.Context())
r.Header.Del("Range")
}
http.ServeContent(w, r, "", object.ModifiedAt, body)
return
}
w.Header().Set("Content-Length", strconv.FormatInt(object.Size, 10))
w.WriteHeader(http.StatusOK)
if r.Method == "GET" {
_, _ = io.Copy(w, object.Body)
}
}
// ServeContent may strip cache headers on errors in newer Go versions. Restore
// the signed-link policy at the actual write, including 304/416 and HEAD errors.
type readResponseWriter struct {
http.ResponseWriter
head, wrote bool
}
func (w *readResponseWriter) WriteHeader(status int) {
if w.wrote {
return
}
w.wrote = true
w.Header().Set("Cache-Control", "private, no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.ResponseWriter.WriteHeader(status)
}
func (w *readResponseWriter) Write(p []byte) (int, error) {
if !w.wrote {
w.WriteHeader(http.StatusOK)
}
if w.head {
return len(p), nil
}
return w.ResponseWriter.Write(p)
}
func (w *readResponseWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
var _ Presigner = (*Signer)(nil)
var _ ReadPresigner = (*Signer)(nil)

173
telemetry/headers.go Normal file
View File

@@ -0,0 +1,173 @@
package telemetry
import (
"context"
"errors"
"net/http"
"sort"
"strings"
"unicode"
"git.yuntuo.hk/k2safe/go-omni/logx"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/trace"
)
// HeaderPropagationConfig maps a legacy or gateway header pair to the
// standard OpenTelemetry trace context. TraceIDHeader and SpanIDHeader must
// carry a 32- and 16-hexadecimal ID respectively. RequestIDHeader is optional
// and is copied to/from the request correlation context.
//
// Legacy headers do not carry a sampling flag. The adapter treats a valid pair
// as sampled so the trace remains visible to the configured exporter. Services
// that need upstream sampling decisions should use the standard traceparent
// header, which always takes precedence.
type HeaderPropagationConfig struct {
TraceIDHeader string `json:"traceIdHeader"`
SpanIDHeader string `json:"spanIdHeader"`
RequestIDHeader string `json:"requestIdHeader,omitempty"`
}
// HeaderPropagator accepts standard W3C trace context and one configured
// legacy header pair. W3C context wins when both formats are present. It also
// propagates the configured request ID without making it part of the span
// parent. Use it only at a boundary that is authorized to supply these IDs.
type HeaderPropagator struct {
config HeaderPropagationConfig
trace propagation.TraceContext
}
// NewHeaderPropagator creates a generic compatibility propagator. Header names
// are canonicalized and compared case-insensitively. It does not trust or
// inspect a Host header; the caller decides which ingress is trusted.
func NewHeaderPropagator(config HeaderPropagationConfig) (*HeaderPropagator, error) {
traceHeader, err := canonicalPropagationHeader(config.TraceIDHeader)
if err != nil {
return nil, errors.Join(errors.New("telemetry: invalid trace ID header"), err)
}
spanHeader, err := canonicalPropagationHeader(config.SpanIDHeader)
if err != nil {
return nil, errors.Join(errors.New("telemetry: invalid span ID header"), err)
}
requestHeader := ""
if strings.TrimSpace(config.RequestIDHeader) != "" {
requestHeader, err = canonicalPropagationHeader(config.RequestIDHeader)
if err != nil {
return nil, errors.Join(errors.New("telemetry: invalid request ID header"), err)
}
}
if strings.EqualFold(traceHeader, spanHeader) ||
(requestHeader != "" && (strings.EqualFold(traceHeader, requestHeader) || strings.EqualFold(spanHeader, requestHeader))) {
return nil, errors.New("telemetry: propagation headers must be distinct")
}
for _, name := range []string{traceHeader, spanHeader, requestHeader} {
if strings.EqualFold(name, "Traceparent") || strings.EqualFold(name, "Tracestate") {
return nil, errors.New("telemetry: custom headers cannot replace W3C trace context")
}
}
config.TraceIDHeader = traceHeader
config.SpanIDHeader = spanHeader
config.RequestIDHeader = requestHeader
return &HeaderPropagator{config: config}, nil
}
func canonicalPropagationHeader(name string) (string, error) {
name = strings.TrimSpace(name)
if name == "" {
return "", errors.New("header name is required")
}
canonical := http.CanonicalHeaderKey(name)
if canonical == "" || strings.IndexFunc(name, unicode.IsSpace) >= 0 || strings.ContainsAny(name, "\r\n:") {
return "", errors.New("header name is malformed")
}
return canonical, nil
}
// Inject writes W3C trace context first and then the configured compatibility
// headers. Existing caller values are replaced so all propagated IDs describe
// the same current span.
func (p *HeaderPropagator) Inject(ctx context.Context, carrier propagation.TextMapCarrier) {
if p == nil || carrier == nil {
return
}
p.trace.Inject(ctx, carrier)
sc := trace.SpanContextFromContext(ctx)
if sc.IsValid() {
carrier.Set(p.config.TraceIDHeader, sc.TraceID().String())
carrier.Set(p.config.SpanIDHeader, sc.SpanID().String())
}
if p.config.RequestIDHeader != "" {
if requestID := validCorrelationID(logx.RequestID(ctx)); requestID != "" {
carrier.Set(p.config.RequestIDHeader, requestID)
}
}
}
// Extract prefers a valid W3C parent. If it is absent or malformed, a valid
// configured legacy pair becomes a remote parent. Invalid legacy IDs are
// ignored, never copied into logs or a new outbound request.
func (p *HeaderPropagator) Extract(ctx context.Context, carrier propagation.TextMapCarrier) context.Context {
if p == nil || carrier == nil {
return ctx
}
ctx = p.trace.Extract(ctx, carrier)
if p.config.RequestIDHeader != "" {
if requestID := validCorrelationID(carrierValue(carrier, p.config.RequestIDHeader)); requestID != "" {
ctx = logx.WithRequestID(ctx, requestID)
}
}
if trace.SpanContextFromContext(ctx).IsValid() {
return ctx
}
traceID, err := trace.TraceIDFromHex(carrierValue(carrier, p.config.TraceIDHeader))
if err != nil || !traceID.IsValid() {
return ctx
}
spanID, err := trace.SpanIDFromHex(carrierValue(carrier, p.config.SpanIDHeader))
if err != nil || !spanID.IsValid() {
return ctx
}
return trace.ContextWithRemoteSpanContext(ctx, trace.NewSpanContext(trace.SpanContextConfig{
TraceID: traceID, SpanID: spanID, TraceFlags: trace.FlagsSampled, Remote: true,
}))
}
// Fields reports both W3C and compatibility fields in stable order.
func (p *HeaderPropagator) Fields() []string {
if p == nil {
return nil
}
fields := []string{"traceparent", "tracestate", p.config.TraceIDHeader, p.config.SpanIDHeader}
if p.config.RequestIDHeader != "" {
fields = append(fields, p.config.RequestIDHeader)
}
sort.Strings(fields)
return fields
}
func carrierValue(carrier propagation.TextMapCarrier, name string) string {
if value := carrier.Get(name); value != "" {
return value
}
for _, key := range carrier.Keys() {
if strings.EqualFold(key, name) {
return carrier.Get(key)
}
}
return ""
}
func validCorrelationID(value string) string {
if value == "" || value != strings.TrimSpace(value) || len(value) > 128 {
return ""
}
for _, r := range value {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' || r == ':' {
continue
}
return ""
}
return value
}
var _ propagation.TextMapPropagator = (*HeaderPropagator)(nil)

View File

@@ -21,10 +21,11 @@ import (
)
type Config struct {
Service string `json:"service"`
SampleRatio float64 `json:"sampleRatio"`
Exporter sdktrace.SpanExporter `json:"-"`
Reader sdkmetric.Reader `json:"-"`
Service string `json:"service"`
SampleRatio float64 `json:"sampleRatio"`
Exporter sdktrace.SpanExporter `json:"-"`
Reader sdkmetric.Reader `json:"-"`
Propagator propagation.TextMapPropagator `json:"-"`
}
type Runtime struct {
traces *sdktrace.TracerProvider
@@ -48,7 +49,11 @@ func New(cfg Config) (*Runtime, error) {
if cfg.Reader != nil {
metricOptions = append(metricOptions, sdkmetric.WithReader(cfg.Reader))
}
r := &Runtime{traces: sdktrace.NewTracerProvider(traceOptions...), meters: sdkmetric.NewMeterProvider(metricOptions...), propagator: propagation.TraceContext{}}
propagator := cfg.Propagator
if propagator == nil {
propagator = propagation.TraceContext{}
}
r := &Runtime{traces: sdktrace.NewTracerProvider(traceOptions...), meters: sdkmetric.NewMeterProvider(metricOptions...), propagator: propagator}
r.tracer = r.traces.Tracer("git.yuntuo.hk/k2safe/go-omni")
meter := r.meters.Meter("git.yuntuo.hk/k2safe/go-omni")
r.duration, _ = meter.Float64Histogram("http.server.request.duration", metric.WithUnit("s"), metric.WithExplicitBucketBoundaries(.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10))
@@ -121,8 +126,14 @@ func (r *Runtime) Middleware() httpx.Middleware {
r.duration.Record(ctx, time.Since(started).Seconds(), metric.WithAttributes(attrs...))
span.End()
// Preserve correlation for outer access-log middleware without
// exposing an ended span as its active span.
c.WithContext(logx.WithTraceID(parent, span.SpanContext().TraceID().String()))
// exposing an ended span as its active span. A custom propagator may
// have supplied a request ID that the built-in RequestID middleware
// did not know about, so carry that value back as well.
outer := logx.WithTraceID(parent, span.SpanContext().TraceID().String())
if requestID := logx.RequestID(ctx); requestID != "" {
outer = logx.WithRequestID(outer, requestID)
}
c.WithContext(outer)
if p != nil {
panic(p)
}

View File

@@ -7,6 +7,7 @@ import (
"git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/telemetry"
"github.com/redis/go-redis/v9"
"go.opentelemetry.io/otel/propagation"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
@@ -115,3 +116,115 @@ func TestHTTPTracePropagationMetricsAndRedisNil(t *testing.T) {
t.Fatal("request metric", count)
}
}
func TestConfiguredHeaderPropagation(t *testing.T) {
exporter := tracetest.NewInMemoryExporter()
propagator, err := telemetry.NewHeaderPropagator(telemetry.HeaderPropagationConfig{
TraceIDHeader: "X-Trace-Id", SpanIDHeader: "X-Span-Id", RequestIDHeader: "X-Request-Id",
})
if err != nil {
t.Fatal(err)
}
runtime, err := telemetry.New(telemetry.Config{Service: "header-test", SampleRatio: 1, Exporter: exporter, Propagator: propagator})
if err != nil {
t.Fatal(err)
}
defer runtime.Close()
const traceID = "11111111111111111111111111111111"
const parentSpanID = "2222222222222222"
const requestID = "gateway-request-1"
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Trace-Id") != traceID || r.Header.Get("X-Request-Id") != requestID {
t.Errorf("custom propagation = trace %q request %q", r.Header.Get("X-Trace-Id"), r.Header.Get("X-Request-Id"))
}
if r.Header.Get("X-Span-Id") == parentSpanID || len(r.Header.Get("X-Span-Id")) != 16 || !strings.HasPrefix(r.Header.Get("Traceparent"), "00-"+traceID+"-") {
t.Errorf("outbound parent propagation = %q", r.Header)
}
io.WriteString(w, "ok")
}))
defer upstream.Close()
app := omni.New(omni.WithLogger(false), omni.WithRequestIDHeader("X-Request-Id"))
defer app.Close()
app.Use(runtime.Middleware())
app.GET("/", func(c *httpx.Context) error {
if got := logx.TraceID(c.Context()); got != traceID {
t.Errorf("inbound trace correlation = %q", got)
}
if got := logx.RequestID(c.Context()); got != requestID {
t.Errorf("inbound request correlation = %q", got)
}
req, _ := http.NewRequestWithContext(c.Context(), http.MethodGet, upstream.URL, nil)
resp, err := (telemetry.Transport{Runtime: runtime}).RoundTrip(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.ReadAll(resp.Body)
return c.OK("ok")
})
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Trace-Id", traceID)
req.Header.Set("X-Span-Id", parentSpanID)
req.Header.Set("X-Request-Id", requestID)
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || rec.Header().Get("X-Request-Id") != requestID {
t.Fatalf("response status/request ID = %d/%q", rec.Code, rec.Header().Get("X-Request-Id"))
}
if err := runtime.Flush(context.Background()); err != nil {
t.Fatal(err)
}
spans := exporter.GetSpans()
var server, client *tracetest.SpanStub
for _, span := range spans {
switch span.SpanKind {
case trace.SpanKindServer:
span := span
server = &span
case trace.SpanKindClient:
span := span
client = &span
}
}
if server == nil || client == nil {
t.Fatalf("missing spans: %d", len(spans))
}
if server.SpanContext.TraceID().String() != traceID || server.Parent.TraceID().String() != traceID || server.Parent.SpanID().String() != parentSpanID || !server.Parent.IsRemote() {
t.Fatalf("server parent = %s/%s remote=%v", server.SpanContext.TraceID(), server.Parent.SpanID(), server.Parent.IsRemote())
}
if client.SpanContext.TraceID().String() != traceID || client.Parent.SpanID() != server.SpanContext.SpanID() {
t.Fatalf("client linkage = trace %s parent %s", client.SpanContext.TraceID(), client.Parent.SpanID())
}
}
func TestConfiguredHeaderPropagationPrefersW3CAndRejectsInvalidSetup(t *testing.T) {
propagator, err := telemetry.NewHeaderPropagator(telemetry.HeaderPropagationConfig{TraceIDHeader: "x-trace-id", SpanIDHeader: "x-span-id"})
if err != nil {
t.Fatal(err)
}
carrier := propagation.MapCarrier{
"traceparent": "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
"X-Trace-Id": "11111111111111111111111111111111",
"X-Span-Id": "2222222222222222",
}
ctx := propagator.Extract(context.Background(), carrier)
parent := trace.SpanContextFromContext(ctx)
if parent.TraceID().String() != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" || parent.SpanID().String() != "bbbbbbbbbbbbbbbb" {
t.Fatalf("W3C parent was not preferred: %s/%s", parent.TraceID(), parent.SpanID())
}
for _, config := range []telemetry.HeaderPropagationConfig{
{TraceIDHeader: "", SpanIDHeader: "X-Span-Id"},
{TraceIDHeader: "X-Trace-Id", SpanIDHeader: ""},
{TraceIDHeader: "X-Trace-Id", SpanIDHeader: "X-Trace-Id"},
{TraceIDHeader: "traceparent", SpanIDHeader: "X-Span-Id"},
{TraceIDHeader: "X Trace", SpanIDHeader: "X-Span-Id"},
} {
if _, err := telemetry.NewHeaderPropagator(config); err == nil {
t.Fatalf("invalid propagation config accepted: %+v", config)
}
}
invalid := propagation.MapCarrier{"X-Trace-Id": "bad", "X-Span-Id": "bad"}
if got := trace.SpanContextFromContext(propagator.Extract(context.Background(), invalid)); got.IsValid() {
t.Fatal("invalid legacy IDs became a parent")
}
}