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

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

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

问题
Gerrit 更改
作者
审核人
提交日期(年-月-日)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 线格格式的二进制兼容性:

  • 魔法数字:用于标识线传格式的修订版本。如果接收器不支持此修订版,则可以确定性地拒绝解码,而不是将错误的解释分配给不匹配的线格格式。
  • 标志:指示此消息中启用的所有软过渡。例如,在从联合体迁移到 x 联合体期间,标志中的某个位用于指示联合体是使用可扩展表示法编码的。

单独使用 FIDL 线格格式时,编码结果中会缺少此信息。我们提议将其中一部分信息纳入编码和解码中。具体而言:

  • 编码会将特定于绑定/语言的域对象转换为 FIDL 编码形式和一个不透明的线格格式元数据 blob,该 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 支持大型消息,并已被用于通过手动将大型值持久化到 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 trait 已针对结构体、联合体和表实现。

具体而言,用户无法再从无中创建持久标头,也无法重复使用同一标头来编码多个消息。

此外,绑定还应提供一种将 WireMetadata 序列化/反序列化为/从字节的方法,以支持将元数据传递出带。

性能

独立编码和解码是 FIDL 事务用法的一部分,而持久性 API 应共享大部分代码路径。因此,我们可以重复使用相同的标准和性能基准。

工效学设计

绑定工效学设计应鼓励采用持久性惯例。例如,绑定可以使用更短、更符合惯例的函数名称来表示持久变种(例如 fidl::persist),并使用更长、更明确的函数名称来表示公共独立编码/解码 API(例如 fidl::standalone::encode)。

向后兼容性

此更改本身是向后兼容的,因为它完全是附加性的,Rust FIDL 持久性实现除外。据我们所知,Rust FIDL 持久性功能的所有当前读取器、写入器和存储数据始终同步演变。

添加了线格格式元数据,以便在即将进行的 FIDL 线格格式迁移中提高未来的向后兼容性。

线格格式元数据包含 5 个预留字节。这些字节日后可能会被重新用于承载其他含义。例如,我们可以使用 1 个字节来描述与持久性相关的问题。

安全注意事项

FIDL 线格格式的验证要求在此处适用,并且具有相同的安全属性。

请注意,FIDL 不是自描述格式。使用一种消息类型成功反序列化已持久化的消息并不保证数据最初是使用该消息类型序列化的:

  • 程序可能会混淆 FIDL 消息是否包含带前缀的元数据标头,或者元数据是否通过带外传递,从而导致输入解析不正确。我们认为,此类错误通常会在测试阶段尽早发现。再加上清晰的文档,这种混淆带来的安全风险应该不大。

  • 恶意攻击者可能会利用路径处理中的漏洞,诱骗程序将类型为 Foo 的持久性 FIDL 消息替换为攻击者控制的另一个类型为 Bar 的消息。这样,恶意攻击者就可以间接影响 Foo 消息的内容。

“替代方案”部分介绍了一种更复杂的格式,该格式通过使用与消息类型相关的信息扩展元数据标头,从而降低了此风险。

隐私注意事项

FIDL 线格格式中的填充字节必须为零,这有助于避免泄露敏感信息。

与 IPC 中快速消耗的暂时性数据相比,持久性数据往往会带来更大的隐私问题,但我们也会将 IPC 数据发送到会将其保留或通过网络发送的组件。因此,IPC 和永久性 API 之间的隐私问题类似。

值得注意的是,即使我们不提供 API,开发者也始终可以通过其他方式(例如 JSON 或 XML)手动保留 FIDL 数据。如果未来的设计提及涉及持久存储用户数据或其他敏感数据,无论方法是通过 FIDL 持久性实现的,都应遵循常规隐私权审核流程。

FIDL API 元素上的隐私注释可简化隐私权审核,并实现更好的下游工具(例如自动隐去内容);但这超出了本文档的范围,本文档仅关注传输 FIDL 消息的特定方法。

测试

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

文档

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

  • 创建一个关于独立编码/解码和持久性的 FIDL 参考页面,以及这两个 API 之间的关系。

    • RustLLCPP 已经有了相关文档。现有文档将更新。
  • 在所有语言的 //examples/fidl/ 中添加演示独立编码解码和持久性的代码,并添加相应的教程。

缺点、替代方案和未知情况

方案 1:仅支持持久性 API

我们可以更进一步,将此惯例规定为标准:所有元数据都必须位于消息载荷之前。虽然这对于我们目前观察到的用例来说已经足够了,但这种做法将来可能会过于死板。通过同时提供无偏见的独立编码/解码 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 限制为非资源类型

主要提案建议使用两种公共 API 来处理 FIDL 线格格式:

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

之所以这样做,是因为我们发现持久性惯例足以满足目前 FIDL 的所有独立用例。

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

另一种方法是提供三种 API:

  • 绑定内部独立编码/解码:可以编码资源类型。供事务消息实现使用。
  • 公开的独立编码/解码:不允许资源类型。
  • 持久性:不允许资源类型。线格格式元数据始终作为一个单元位于载荷之前。

当用例需要单独传输或存储线格格式元数据时,这会改进非资源保证,但会导致 API 令人困惑,因为我们最终会得到两种编码/解码 API,唯一的区别是支持的资源类型。由于目标语言限制,这种情况会更加复杂,有时很难将 API 从公共接口中隐藏起来。

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

在先技术和参考文档