| RFC-0120:独立使用 FIDL 线格式 | |
|---|---|
| 状态 | 已接受 |
| 区域 |
|
| 说明 | 此 RFC 正式规定了在没有传输的情况下使用(即编码和解码)FIDL 有线格式的要求。它还指定了有关绑定应如何公开此功能的评分标准。 |
| 问题 | |
| Gerrit 更改 | |
| 作者 | |
| 审核人 | |
| 提交日期(年-月-日) | 2021-07-02 |
| 审核日期(年-月-日) | 2021-08-04 |
摘要
此 RFC 正式规定了在没有传输的情况下使用(即编码和解码)FIDL 线格式的要求。它还指定了有关绑定应如何公开此功能的评分标准。我们引入了线格式元数据的概念,用于描述线格式的修订版本和功能,并要求在编码和解码 API 中使用该元数据,以便:
- 绑定正式支持在不使用传输的情况下使用 FIDL 线格式。
- 用户必须随编码后的消息一起传输线格式元数据。
- 绑定可能支持一种持久性惯例,即消息以元数据为前缀。
设计初衷
Fuchsia 的一项核心原则是可更新。当 FIDL 用于 IPC 上下文(例如两个对等方通过 Zircon 渠道使用 FIDL 协议)时,我们在 ABI 兼容性方面投入了大量精力。另一方面,FIDL 有线格式的独立使用场景相对较少,因此在兼容性方面受到的关注较少。例如,有时会错误地认为,仅传递 FIDL 消息的编码字节会导致 ABI 可演变。
驱动程序元数据 RFC 和 RFC-0109:快速数据报套接字 都要求通过面向字节的接口发送 FIDL。现在是时候正式确定 FIDL 传输格式的独立用途,以便为它们提供发展和互操作性保证。
设计
绑定必须支持在没有传输的情况下对 FIDL 有线格式进行编码和解码,该 API 的要求详见下文。请注意,许多绑定已经具有某种形式的公共编码/解码 API(例如,高级别 C++ 绑定中的 fidl::Encode)。应根据此 RFC 进行调整。因此,RFC 的这一部分可以看作是对核心功能的正式化,明确了 FIDL 的分层。
FIDL 线路格式
FIDL 有线格式的重点是二进制兼容性:一组围绕架构演变的保证,以支持读取使用不同版本的架构写入的数据。例如,布局为 struct{uint8;uint8;} 的类型可能会演变为布局 struct{uint16;}。虽然 FIDL 提供可扩展的数据结构,但这些结构不支持有线格式本身的演变,例如将 FIDL 表切换为更高效的表示形式。事务性标头中的两项信息有助于在通过协议和传输使用时实现 FIDL 线格式的二进制兼容性:
- 幻数:用于标识传输格式的修订版本。如果接收器不支持此修订版本,则可以确定性地拒绝解码,而不是为不匹配的序列化格式分配错误的解释。
- 标志:表示相应消息中启用的所有软过渡。例如,在从联合迁移到 xunion 的过程中,标志中的一个位用于指示联合是否使用可扩展表示法进行编码。
当 FIDL 线格式单独使用时,编码结果中会缺少此信息。我们建议将该信息的一部分纳入编码和解码过程。具体而言:
- 编码将绑定/语言特定的网域对象转换为 FIDL 编码形式和描述所用线格式的修订版本和功能的线格式元数据的不透明 Blob。
- 解码会使用编码形式的 FIDL 消息和相应的线格式元数据,从而生成绑定/语言特定的网域对象。
在伪代码中,它们将具有以下函数签名:
function Encode<T>(object: T) -> (EncodedMessage, WireFormatMetadata);
function Decode<T>(message: EncodedMessage, metadata: WireFormatMetadata) -> T;
EncodedMessage 包含编码的字节以及消息中的所有句柄。大多数绑定都会定义用途相似或相同的类型。
有线格式元数据本身将具有与 64 位整数兼容的 ABI。其布局如下所示(采用伪 C 表示法):
struct fidl_wire_format_metadata_t {
uint8_t disambiguator;
uint8_t magic_number;
uint8_t at_rest_flags[2];
uint8_t reserved[4];
};
RFC-0138:处理未知互动 建议将事务性标头中的标志细分为 dynamic_flags(与协议的请求/响应互动模型相关)和 at_rest_flags(与线缆格式相关)。此 RFC 假设了这种设计,但可以轻松进行相应调整,而不会失去其关键属性。
有线格式元数据应具有 8 字节的对齐方式,以便于对消息进行就地解码。绑定必须在外部将元数据表示为一个不透明的结构,该结构长 8 字节,对齐方式为 8 字节(例如,包含单个 uint64 字段的结构)。这样一来,元数据本身就可以不断发展,因为用户不会依赖元数据中的特定字段。
绑定必须检查 reserved 字节是否为零。绑定不得依赖于 at_rest_flags 字节具有任何特定值。绑定必须验证 magic_number 是否表示受支持的线格式修订版本。
绑定必须检查 disambiguator 字节是否为零。在元数据前面添加零字节可防止程序在 FIDL 消息持久化为文件时将其误认为文本(请参阅数据持久化惯例)。
请注意,FIDL 事务性标头中包含的信息是线格式元数据中包含的信息的超集。事务性标头和有线格式元数据中的 at_rest_flags 字段和 magic_number 字段的语义是相同的。
每条消息都必须仅与其对应的元数据一起使用。换句话说,不允许共享元数据(例如,使用元数据 A 同时解码消息 A 和消息 B)或交换元数据(例如,使用元数据 A 解码消息 B,并使用元数据 B 解码消息 A)。这样一来,便可在运行时更改消息线格式修订版本,例如在线格式软迁移期间。
绑定必须支持以下顶级类型的独立使用:
- 结构体
- 表格
- Union
如果给定任何其他数据类型,编码和解码函数必须失败。如果可能,失败应发生在编译时。
FIDL 语言并未规定如何传输线格式元数据或如何将线格式元数据与编码后的消息相关联。例如,当在生产 IPC 环境中使用 FIDL 时,元数据可以从事务性消息标头派生。
数据持久性方面的惯例
为了更好地支持相关用例,我们希望指定一种将元数据附加到编码消息的惯例,其中消息的字节内容以元数据为前缀。绑定应支持这种带有前缀的独立序列化格式用法,称为持久性。
以下持久性使用情形属于适用范围:
- 将单个 FIDL 对象写入不支持传输 Zircon 句柄的网络、磁盘或其他面向字节/数据包的接口,而无需选择加入请求/响应范式。换句话说,数据处于“静态”状态。
- 支持大于 64 KiB 的消息。64 KiB 的消息大小限制是 Zircon 渠道传输的属性。将消息持久保存到字节向量时,没有此类限制。现有的 Rust 持久性 API 支持大型消息,并且已用于解决渠道消息大小限制问题,方法是在内置 FIDL 支持大型消息之前,手动将大型值持久保存到 VMO 中。
以下用例不在范围内:
- 内置支持对同一类型的消息序列进行编码。 应用可以定义自定义的流式传输方法,以便更好地满足其特定使用情形。
使用这种带前缀的 API 风格可在以下几个方面提高人体工程学和安全性:
- 用户无需手动跟踪数据与元数据之间的关联。数据只是遵循元数据,可以作为一个单元发送。相比之下,带外传递元数据会增加版本不匹配的风险。如果接收器需要处理多路复用到同一持久性介质中的多个有线格式版本,则情况会更加复杂:
- 当流式 API 中的发送者更改身份时,新发送者可能使用与原始发送者不同的有线格式修订版本。
- 假设有一个代理,它使用不同的有线格式修订版本从多个组件接收持久性消息,并将这些消息存储到数据库中。代理必须将带外风格转换回带前缀的风格,才能保留不同的有线格式修订版本。
- 简化了缓冲区管理,并可能提高了性能,如果在热路径中使用独立线格式,则会带来好处。例如,绑定可以分配一个缓冲区来同时保存元数据和载荷,也可以描述一个向量化写入,其中第一个元素指向元数据。
- 用户无需针对多种语言和客户端库重新实现传递元数据的相同逻辑,因为 FIDL 已经提供了一种实现。
持久性 API 必须支持以下顶级类型:
- 非资源结构体
- 非资源表
- 非资源联合
如果提供任何其他数据类型,持久化必须失败。如果可能,失败应发生在编译时。
在伪代码中,持久性 API 将具有以下函数签名:
function Persist<T>(object: T) -> vector<uint8>;
function Unpersist<T>(bytes: vector<uint8>) -> T;
绑定可以使用目标语言中最合适的替代命名/方法签名,只要它们从数据流的角度来看符合 API 的形状即可。
绑定可以支持矢量化 Persist 变体,该变体支持矢量化输出,例如生成链接到多个缓冲区的 zx_channel_iovec_t 或 zx_iovec_t,或者与目标语言的惯用写入器接口集成。如果绑定已在 IPC 代码路径中使用矢量化变体,则应提供该变体。
绑定可以支持采用矢量化输入的矢量化 Unpersist 变体,例如使用链接到多个缓冲区的 zx_iovec_t,或与目标语言的惯用读取器接口集成。
请注意,持久化会生成字节,而单独的编码/解码可能会生成句柄。
绑定必须支持持久保留导致编码后消息大小超过 64 KiB 的大型值。
应更新 FIDL 样式指南和 API 评分标准,以纳入持久性注意事项:
- 明确指明二进制 blob 是使用持久性惯例还是自定义/带外机制来传递元数据。
FIDL 源语言
此 RFC 不会更改 FIDL 源语言。
实现
绑定应调整其独立编码/解码 API,以与涉及元数据的提议设计保持一致。它们不必完全遵循函数签名,只要从数据依赖性的角度来看,这些函数与提案一致即可。例如,解码器的行为必须可通过元数据以某种方式进行配置。
应使用相同的独立编码/解码 API 来实现消息传递,例如通过 Zircon 渠道进行事务性消息分派。
可以单独添加对各种持久性 API 的绑定支持。
Rust 绑定中已存在持久性 API 的实现,但数据格式和 API 与此 RFC 中的设计不匹配。Rust 实现将进行调整,以与已接受的设计保持一致。
Rust 更改
目前,Rust 绑定提供以下函数:
fn create_persistent_header() -> PersistentHeader;
fn encode_persistent_header(header: &mut PersistentHeader) -> Result<Vec<u8>>;
fn encode_persistent<T: Persistable>(body: &mut T) -> Result<Vec<u8>>;
fn encode_persistent_body<T: Persistable>(body: &mut T, header: &PersistentHeader) -> Result<Vec<u8>>;
fn decode_persistent<T: Persistable>(bytes: &[u8]) -> Result<T>;
fn decode_persistent_header(bytes: &[u8]) -> Result<PersistentHeader>;
fn decode_persistent_body<T: Persistable>(bytes: &[u8], header: &PersistentHeader) -> Result<T>;
应将这些变量替换为以下变量(确切签名可能会因借用和生命周期细节而有所不同):
fn persist<T: Persistable, W: std::io::Write>(body: &mut T, writer: W) -> Result<()>;
fn unpersist<T: Persistable, R: std::io::Read>(reader: R) -> Result<T>;
fn standalone_encode<T: TopLevel, W: std::io::Write, H: core::iter::Extend<HandleDisposition>>(body: &mut T, writer: W, out_handles: &mut H) -> Result<WireMetadata>;
fn standalone_decode<T: TopLevel, R: std::io::Read>(reader: R, handles: &mut [HandleInfo], metadata: &WireMetadata) -> Result<T>;
struct WireMetadata { /* private fields */ }
TopLevel 特征已针对结构、联合和表实现。
具体而言,用户无法再凭空创建持久性标头,也无法再重复使用同一标头来编码多条消息。
此外,绑定应提供一种将 WireMetadata 序列化/反序列化为字节或从字节序列化/反序列化的方法,以支持带外传递元数据。
性能
独立编码和解码是 FIDL 事务性用例的一部分,而持久性 API 应共享大部分代码路径。因此,我们可以沿用相同的标准和效果基准。
工效学设计
绑定人体工程学设计应鼓励采用持久性惯例。例如,绑定可以使用更短且更惯用的函数名称来表示持久性风格(例如 fidl::persist),并使用更长且更明确的函数名称来表示公共独立编码/解码 API(例如 fidl::standalone::encode)。
向后兼容性
此变更本身是向后兼容的,因为它是纯粹的增量变更,但 Rust FIDL 持久性实现除外。据我们所知,Rust FIDL 持久性的所有当前读取器、写入器和存储的数据始终同步演变。
添加线格式元数据有助于提高未来的向后兼容性,以应对即将到来的 FIDL 线格式迁移。
有线格式元数据包含 5 个预留字节。这些字节可能会在未来重新用于表示其他含义。例如,我们可能会使用一个字节来描述与持久性相关的问题。
安全注意事项
FIDL 有线格式的验证要求也适用于此处,并具有相同的安全属性。
请注意,FIDL 不是自描述格式。成功使用一种消息类型将持久性消息反序列化,并不保证数据最初是使用该消息类型序列化的:
程序可能会混淆 FIDL 消息是否包含带前缀的元数据标头,或者元数据是否带外传递,从而导致输入解析不正确。我们认为,此类错误往往会在测试阶段被尽早发现。如果文档清晰明了,这种混淆带来的安全风险应该很小。
恶意行为者可能会利用路径处理中的漏洞,诱骗程序将类型为
Foo的持久性 FIDL 消息覆盖为攻击者控制的另一种类型Bar的消息。这样,恶意方就可以间接影响Foo消息的内容。
替代方案部分介绍了一种更复杂的格式,通过扩展元数据标头以包含有关消息类型的信息,来降低此风险。
隐私注意事项
FIDL 有线格式中的填充字节必须为零,这有助于避免泄露敏感信息。
与快速消耗的 IPC 中的临时数据相比,持久性数据往往会带来更大的隐私问题,但我们也有 IPC 数据被发送到会持久保存或通过网络发送它的组件。因此,IPC 和持久性 API 之间的隐私问题类似。
值得注意的是,即使我们不提供 API,开发者也始终可以通过其他方式(例如 JSON 或 XML)手动保留 FIDL 数据。如果未来的设计提及会涉及持久保留用户或其他敏感数据,无论方法是否是通过 FIDL 持久保留,都应进行常规的隐私权审核。
FIDL API 元素上的隐私权注释将简化隐私权审核并实现更好的下游工具(例如自动编辑);它们不在本 RFC 的范围内,本 RFC 侧重于传输 FIDL 消息的特定方法。
测试
我们将扩展 GIDL(FIDL 一致性测试套件),以测试持久性格式的编码和解码。
文档
创建有关 FIDL 的参考页面,介绍独立编码/解码和持久性,以及这两个 API 之间的关系。
在所有语言的
//examples/fidl/中添加了演示独立编码、解码和持久性的代码,并添加了相应的教程。
缺点、替代方案和未知因素
替代方案 1:仅支持持久性 API
我们可以进一步规范此惯例,并将其指定为标准:所有元数据都必须位于消息载荷之前。虽然这种方向足以应对我们目前观察到的使用情形,但未来可能会变得过于僵化。通过同时提供无主观意见的独立编码/解码 API 和有主观意见的持久性 API,用户将能够选择最适合其设计的一个。
替代方案 2:允许共享线格式元数据
我们可以允许在会话中为所有消息共享相同的元数据。 这样,元数据就可以在开始时发送一次,然后在两个对等方之间的其余通信中省略。这可用于通过一个持久性媒体实例来传输多个 FIDL 对象。例如,快速数据报套接字 RFC 可以先通过套接字发送元数据,然后以编码形式发送多个对象,从而避免向每个 UDP 数据报添加 8 个字节。
为此,我们施加了一个限制,即元数据必须独立于正在编码的特定消息,并且仅依赖于编译到消息生产者中的 FIDL 运行时版本。这也意味着,如果 FIDL 编码器支持多种线格式表示法,则不得在运行时随意切换线格式表示法。
元数据带来的 8 字节额外税费似乎并不高昂。始终将此信息与消息一起发送,可大大减轻用户端额外的元数据跟踪复杂性。
对于确实需要不包含元数据的原始性能的用例,我们可以考虑在 FIDL 中原生添加流式传输功能。例如,可以想象一下,定义一个通过套接字传输单一类型值的传输。可以实现绑定来尽可能跳过发送元数据,并禁止更改发送者/接收者身份(例如,每次引入新对等方时都必须重新建立传输)。
@stream
protocol UdpSocketPayload over zx.socket {
Send(SendMsgPayload);
-> Recv(RecvMsgPayload);
};
元数据始终特定于消息,这还支持按消息使用标志。例如,我们可以使用一个标志来指示正文是否经过压缩。我们还可以设计一种更紧凑的线路表示形式,这种表示形式在内存中高效 IPC 的限制方面旋转得更少(例如,无需为指针或对齐预留空间)。替代格式可以通过元数据预留区域中的另一个位来指示。
替代方案 3:使用事务性消息标头
持久化期间的线格式元数据可以与事务性消息标头兼容。
为此,我们会将持久性视为一种传输方式,并使用一种写入对象的方法:
@persistence
type Metadata = table {
1: foo int32;
2: bar vector<int64>;
};
// desugars to
protocol MetadataSink over persistence {
// Ordinal is the hash of `mylib/MetadataSink.Metadata`.
Metadata(Metadata);
};
此方法具有以下优势:
- 重复使用现有的 FIDL 功能。持久性与通过渠道进行消息传递相同,只不过您将字节写入其他类型的接收器(vmo、文件、套接字)。我们还可以在以后添加流式传输或多条消息支持,方法是添加控制数据包(控制序号),用于告知消息大小等信息。
- 通过重复使用序号哈希检查来减少串扰并提高安全性:攻击者无法通过在数据平面(载荷)中调整某些字节来伪造其他类型的消息。此策略与 FIDL 方法通过渠道实现的安全属性保持一致。
这似乎是一个优雅的传输泛化案例,但结果将是一个非常奇怪的协议,它仅是单向的,并且只能执行一次:客户端只能发送一种值,并且只能发送一次。接收者无法做出回应。这与我们在协议层面添加的演变功能(例如开放式和封闭式互动)不太契合。
替代方案 4:使用消息类型信息扩展线格式元数据
与替代方案 3 相比,我们还可以采取一种不那么冒险的步骤,即对持久化消息的完全限定类型名称进行哈希处理,并将其添加到有线格式元数据中,以标识正在持久化的消息的类型,而无需引入成熟的传输概念。
这样可以减少串扰,但仍存在其他细微的复杂性:
- 如何处理类型的重命名:持久化类型的名称现在成为 ABI 的一部分,因为它会影响元数据中的哈希。
- 如何将此与 FIDL 的事务性 IPC 用途相协调:主要提案将独立编码/解码 API 制定为 FIDL 的较低阶核心功能,事务性 IPC 功能可以基于此功能构建。这种替代方案会产生两种不同的功能。具体来说,无法从事务性消息标头派生出有线格式元数据,因为后者使用的方法序号哈希对于请求类型和响应类型都是相同的。
总而言之,我们认为这种替代方案所能防范的安全风险并不值得为此付出额外的复杂性。
替代方案 5:将独立 API 限制为非资源类型
主要提案建议了两种处理 FIDL 线路格式的公共 API:
- 独立编码/解码:可能会对资源类型进行编码并生成句柄。由 FIDL 事务性消息传递实现(客户端和服务器绑定)共享。
- 持久性:不允许使用资源类型;结果是纯数据。有线格式元数据始终作为一个单元位于载荷之前。
这是因为我们发现,目前的 FIDL 持久性惯例足以满足所有独立用例的需求。
在未来的使用情形中,可能更适合单独传输或存储线格式元数据和载荷。他们可能会使用独立的编码/解码 API,但这样做会带来一个缺点,即允许在 API 中使用句柄,这可能是不必要的。
另一种方法是提供三种类型的 API:
- 绑定内部独立编码/解码:可能会对资源类型进行编码。供事务性消息传递实现使用。
- 公开的独立编码/解码:不允许使用资源类型。
- 持久性:不允许使用资源类型。线格式元数据始终作为单个单元位于载荷之前。
当用例需要单独传输或存储有线格式元数据时,这会提高非资源保证,但会造成 API 混乱,因为我们最终会得到两种编码/解码 API,唯一的区别是支持资源类型。目标语言的限制使情况更加复杂,有时很难从公共界面隐藏 API。
主要提案采用简化方法,将此替代方案中的前两种 API 合并在一起。