RFC-0120:单独使用 FIDL 有线格式

RFC-0120:单独使用 FIDL 有线格式
状态已接受
领域
  • FIDL
说明

此 RFC 正式说明了在没有传输的情况下使用(即编码和解码)FIDL 有线格式的要求。还指定了关于绑定应如何公开此功能的评分准则。

问题
  • 45252
Gerrit 更改
  • 551813
作者
审核人
提交日期(年-月-日)2021-07-02
审核日期(年-月-日)2021-08-04

总结

此 RFC 正式说明了在没有传输的情况下使用 FIDL 有线格式(即编码和解码)的要求。还指定了关于绑定应如何公开此功能的评分准则。我们引入了有线格式元数据的概念,用于描述有线格式的修订版和特征,并要求在编码和解码 API 中使用有线格式元数据,以便:

  • 绑定正式支持使用 FIDL 有线格式,无需传输。
  • 用户必须随编码消息一起传输有线格式元数据。
  • 绑定可能支持持久性约定,其中消息以元数据为前缀。

设计初衷

Fuchsia 的核心原则是可更新。在 IPC 环境中使用 FIDL 时(例如两个对等方通过 Zircon 通道使用 FIDL 协议时),我们在提升 ABI 兼容性。另一方面,FIDL 有线格式的独立用例相对较少,因此不太注重兼容性。例如,人们有时会错误地认为,仅传递 FIDL 消息的编码字节会导致生成可演进的 ABI。

驱动程序元数据 RFCRFC-0109:快速数据报套接字都要求通过字节型接口发送 FIDL。现在不妨正式确定 FIDL 有线格式的独立用途,以便为它们提供演变和互操作性保证。

设计

绑定必须支持在无传输的情况下对 FIDL 有线格式进行编码和解码,下面详细介绍了该 API 的要求。请注意,许多绑定已经具有某种形式的公共编码/解码 API(例如,高级 C++ 绑定中的 fidl::Encode)。应根据此 RFC 对其进行调整。因此,RFC 的这一部分可以看作一项核心功能的规范化,阐明了 FIDL 的分层。

FIDL 传输格式

FIDL 传输格式的重点是二进制兼容性:一组围绕架构演变提供的保证,支持读取使用该架构的不同版本写入的数据。例如,布局为 struct{uint8;uint8;} 的类型可能会演变为布局 struct{uint16;}。虽然 FIDL 提供了可扩展的数据结构,但这些结构不支持传输格式本身的演变,例如将 FIDL 表切换为更高效的表示形式。通过协议和传输时,事务标头中的两条信息有助于 FIDL 有线格式的二进制文件兼容性:

  • 魔数:标识传输格式的修订版本。如果接收器不支持此修订版本,则可以确定性地拒绝解码,而不是向不匹配的传输格式分配错误解释。
  • 标志:指示此消息中启用的任何软转换。例如,在并集到并集迁移期间,标记中的某个位用于表示并集是使用可扩展表示法进行编码的。

如果单独使用 FIDL 传输格式,编码结果中会缺少此信息。我们建议将其中一部分信息整合到编码和解码中。具体而言:

  • Encoding 会将绑定/特定于语言的网域对象转换为 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)。这样一来,消息传输格式修订版本就可以在运行时更改,例如在有线格式软迁移期间。

绑定必须支持单独使用以下顶级类型:

  • 结构体
  • 表格
  • 联合

给定任何其他数据类型,编码和解码函数都必须失败。故障应尽可能发生在编译时。

FIDL 语言没有规定传输格式元数据如何传输或与编码消息相关联。例如,在正式版 IPC 环境中使用 FIDL 时,元数据可能来自事务性消息标头。

数据持久性惯例

为了更好地支持激励用例,我们希望指定一个惯例,将元数据附加到编码消息,其中消息的字节内容以元数据为前缀。绑定应支持这种带前缀的独立传输格式用法(称为持久性)。

以下持久性用例均在讨论范围内:

  • 在不选择启用请求/响应范式的情况下,将单个 FIDL 对象写入网络、磁盘或其他不支持传输 Zircon 句柄的网络、磁盘或其他字节/数据包导向型接口。换句话说,数据是“静态”的。
  • 支持大于 64 KiB 的消息。64 KiB 消息大小限制是 Zircon 通道传输的一个属性。将消息持久存储到字节矢量时,不存在此类限制。现有的 Rust 持久性 API 支持大型消息,并已通过手动将大型值保留到 VMO 中,解决了信道消息大小限制(这取决于对大型消息的内置 FIDL 支持)。

以下用例不在讨论范围内:

  • 内置对同一类型的序列消息进行编码的内置支持。应用可以定义更适合其特定用例的自定义流式传输方法。

使用此带前缀的 API 变种可以从多种方式提高工效学设计和安全性:

  • 用户不必手动跟踪数据和元数据之间的关联。数据跟随元数据,可以作为一个单元发送。相比之下,向带外传递元数据会增加版本不匹配的风险。当接收器需要处理复用到同一持久性媒介的多个有线格式版本时,会额外增加复杂性:
    • 当流式传输 API 中的发送者更改身份时,新发送者所用的传输格式修订版本可能与原始发送者不同。
    • 假设有一个代理,该代理使用不同传输格式修订版本从多个组件接收持久性消息,并将其存储到数据库中。代理必须将带外变种转换回带有前缀的变种,以保留不同的传输格式修订版本。
  • 可简化缓冲区管理,并可提升性能;如果在热路径中使用独立的有线格式,这会有益。例如,绑定可以分配一个缓冲区来同时保存元数据和载荷,或者描述单个矢量化写入(其中第一个元素指向元数据)。
  • 用户无需重新实现相同的逻辑来以多种语言和客户端库传递元数据,因为 FIDL 已提供一个实现。

持久性 API 必须支持以下顶级类型:

  • 非资源结构体
  • 非资源表
  • 非资源联合

指定任何其他数据类型时,持久性必须失败。故障应尽可能发生在编译时。

在伪代码中,持久性 API 将具有以下函数签名:

function Persist<T>(object: T) -> vector<uint8>;
function Unpersist<T>(bytes: vector<uint8>) -> T;

绑定可以使用目标语言最合适的替代命名/方法签名,前提是它们从数据流的角度遵循 API 的形状。

绑定可以支持支持矢量化输出的矢量化 Persist 变体,例如生成关联到多个缓冲区的 zx_channel_iovec_tzx_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 消息的特定方法。

测试

我们将扩展 FIDL 一致性测试套件 GIDL,以测试持久性格式的编码和解码。

文档

  • 扩充绑定规范,在其中添加此 RFC 中添加的要求(例如 LLCPP)。

  • 创建有关 FIDL 独立编码/解码和持久性的参考页面,并说明这两个 API 之间的关系。

    • RustLLCPP 已具有相关文档。现有文档将进行更新。
  • 添加到所有语言的 //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 相比,方案 3 是一个不太冒险的步骤,我们可以对持久化消息的完全限定类型名称进行哈希处理,并将其添加到有线格式元数据中,以标识持久保留的消息的类型,而不会引入成熟传输的概念。

这样可以减少串扰,但仍然会有其他微妙的复杂性:

  • 如何处理类型的重命名:持久保留类型的名称现在会成为 ABI 的一部分,因为它会影响元数据中的哈希。
  • 如何使这一点与使用 FIDL 的事务性 IPC 相协调:主要方案将独立的编码/解码 API 打造为 FIDL 的较低级别核心功能,可在此基础上构建事务性 IPC 功能。这种替代方案会产生两项单独的功能。具体而言,传输格式元数据不能从事务消息标头中派生,因为后者使用的方法序数哈希对请求和响应类型都相同。

总体而言,我们认为此替代方案带来的安全风险不值得额外增加复杂性。

替代方案 5:将独立 API 限制为非资源类型

主要方案建议使用两种用于处理 FIDL 传输格式的公共 API:

  • 独立编码/解码:可对资源类型进行编码并生成句柄。 由 FIDL 事务性消息传递实现(客户端和服务器绑定)共享。
  • 持久性:不允许资源类型;结果是纯数据。有线格式元数据始终作为一个单元放在载荷之前。

这是因为以下观察结果:持久性约定足以满足当今的所有独立 FIDL 用例。

未来的用例可能更适合单独传输或存储有线格式元数据和载荷。他们可以使用独立的编码/解码 API,但缺点是在 API 中允许使用句柄,这可能是没有根据的。

作为替代方案,您可以提供以下三种 API:

  • 绑定内部独立编码/解码:可对资源类型进行编码。用于事务性消息传递实现。
  • 公共独立编码/解码:不允许资源类型。
  • 持久性:不允许资源类型。有线格式元数据始终作为一个单元放在载荷之前。

当用例需要单独传输或存储传输格式元数据时,这样可以改善非资源保证,但会导致 API 混淆,因为我们最终获得了两种编码/解码 API,唯一的区别是支持资源类型。目标语言限制加剧了这一问题,有时很难在公开途径中隐藏某个 API。

主方案采用简化路径,将此替代方案中的前两种 API 合并在一起。

早期技术和参考资料