For AI agents: the complete documentation index is available at /tc39-atlas/llms.txt, the full documentation bundle is available at /tc39-atlas/llms-full.txt, and this page is available as Markdown at /tc39-atlas/proposals/year/pending/proposal-function-implementation-hiding.md.
  • 简体中文
  • Function implementation hiding S2

    中文标题:函数实现隐藏

    提案概览
    提案速览

    该提案引入了两个新指令,"hide source" 和 "sensitive",以隐藏 Function.prototype.toStringError.prototype.stack 中的实现细节。它旨在防止消费者依赖内部细节并保护安全敏感的应用程序。

    Note

    以下 README 来自上游仓库,其中的阶段或状态标注可能滞后;当前信息以提案概览为准。

    函数实现隐藏提案

    这是一个关于一对新指令的提案,暂定名为 "sensitive""hide source",它们为开发者提供了一种方式来指示某些实现细节不应暴露给其他用户代码。这对于希望在不担心破坏依赖其实现细节的消费者的情况下进行重构的库代码作者、安全敏感代码的作者以及 polyfill 的作者等都有好处。

    实际上,"hide source" 指令会隐藏由 Function.prototype.toString 揭示的源代码文本,以及由 Error.prototype.stack 揭示的文件归属和位置信息。"sensitive" 指令会隐藏由 Function.prototype.toString 揭示的源代码文本,并将该函数完全从 Error.prototype.stack 中省略。"sensitive" 指令旨在未来随着新的影响安全的泄露被发现或添加到语言中而扩展。

    此提案在 TC39 流程 中处于第 2 阶段,并最近一次在 2019 年 7 月 向委员会进行了演示。

    问题

    Function.prototype.toString

    JavaScript 的 Function.prototype.toString 方法揭示了最初用于创建函数的源代码文本。揭示函数的源代码文本会让调用者对其实施细节有不必要的了解。他们可以内省函数的实现并对其做出反应,从而导致原本无害的重构变成破坏性更改。他们还可以从源代码文本中提取秘密值,以破坏应用程序对封装的尝试。

    一个历史上的例子是 AngularJS 依赖于 f.toString() 来检查函数的参数名称。然后它会将这些名称用作其依赖注入框架的一部分。这使得以前非破坏性的更改 — 修改参数名称 — 变成了破坏性的更改。特别是,这种对 Function.prototype.toString 的使用使得无法将原本保持语义的压缩工具与此类 AngularJS 结合使用。

    f.toString() 获得的另一个不必要的洞察是函数是如何创建的。最显著的是,它可以用于通过查找 [native code] 的模式来检测实现是否是“原生的”。这使得高保真 polyfilling 变得困难;事实上,一些狂热的 polyfill 库已经采取行动来替换 Function.prototype.toString 或引入自有属性 polyfillFn.toString() 以防止检测(1, 2)。这之所以可能,仅仅是因为引擎具有开发者无法使用的实现隐藏能力。最后,f.toString() 还可以用于检测实现是通过 classfunction、箭头函数、方法等完成的。如果这些在很大程度上是不可检测的实现细节,那就更好了,这允许在它们之间进行重构时更有信心。

    Error.prototype.stack

    JavaScript 的(非标准但事实标准的)Error.prototype.stack 获取器会揭示在存在/不存在堆栈帧的情况下的调用行为,或者在递归函数的情况下,揭示特定函数在堆栈跟踪中出现的次数。如果此调用行为依赖于秘密值,无论这些秘密值是在函数源代码文本中还是仅对该函数在词法上可用,都可能导致这些秘密部分或完全暴露。此外,Error.prototype.stack 揭示了位置(行/列号)信息和文件归属信息,这以类似于 Function.prototype.toString 暴露函数源代码文本的方式限制了重构。

    解决方案

    解决方案是提供一种修改上述函数输出的方法,以防止它们暴露实现细节。这是通过上述描述的新指令实现的,暂定名为 "hide source""sensitive"。与 "use strict" 一样,它们可以应用于整个源文件或按函数应用。

    "use strict" 指令类似,这些新指令“向下包含”应用,因此作用域内的所有内容,以及在函数作用域内包括函数本身,都会被隐藏。例如:

    function foo() {
      const x = () => {};
    
      const y = () => {
        "hide source";
        class Z {
          m() {}
        }
      };
    }

    在此示例中,foox 保持不隐藏,而 yZZ.prototype.m 被视为隐藏。

    注意:历史上,人们也探索过用于隐藏实现细节的带外、应用程序级开关,通常是出于内存使用的考虑。有关更多讨论,请参阅附录

    为什么使用指令序言?

    此提案充分利用了 JavaScript 现有的指令序言支持 的优势:

    • 它允许轻松隐藏整个源文件中所有函数的实现细节。
    • 同时,它允许通过使用匿名函数包装器块轻松地将隐藏和非隐藏代码捆绑在一起。
    • 它是向后兼容的,允许轻松部署尽最大努力隐藏其实现的代码。在不实现此提案的引擎中,"hide source" 将是一个不操作。
    • 它是词法的,因此允许工具轻松且静态地确定函数的实现是否被隐藏。例如,内联器会知道不应将实现隐藏的函数内联到实现暴露的函数中。

    示例

    堆栈跟踪:

    $ node
    欢迎使用 Node.js v12.13.0。
    输入 ".help" 获取更多信息。
    > console.log((new Error).stack)
    Error
        at repl:1:14
        at Script.runInThisContext (vm.js:116:20)
        at REPLServer.defaultEval (repl.js:404:29)
        at bound (domain.js:420:14)
        at REPLServer.runBound [as eval] (domain.js:433:12)
        at REPLServer.onLine (repl.js:715:10)
        at REPLServer.emit (events.js:215:7)
        at REPLServer.EventEmitter.emit (domain.js:476:20)
        at REPLServer.Interface._onLine (readline.js:316:10)
        at REPLServer.Interface._line (readline.js:693:8)
    undefined
    > 

    如果 bound 被标记为 "sensitive",则相同的堆栈跟踪:

    $ node
    欢迎使用 Node.js v12.13.0。
    输入 ".help" 获取更多信息。
    > console.log((new Error).stack)
    Error
        at repl:1:14
        at Script.runInThisContext (vm.js:116:20)
        at REPLServer.defaultEval (repl.js:404:29)
        at REPLServer.runBound [as eval] (domain.js:433:12)
        at REPLServer.onLine (repl.js:715:10)
        at REPLServer.emit (events.js:215:7)
        at REPLServer.EventEmitter.emit (domain.js:476:20)
        at REPLServer.Interface._onLine (readline.js:316:10)
        at REPLServer.Interface._line (readline.js:693:8)
    undefined
    > 

    如果 bound 被标记为 "hide source",则相同的堆栈跟踪:

    $ node
    欢迎使用 Node.js v12.13.0。
    输入 ".help" 获取更多信息。
    > console.log((new Error).stack)
    Error
        at repl:1:14
        at Script.runInThisContext (vm.js:116:20)
        at REPLServer.defaultEval (repl.js:404:29)
        at bound (<anonymous>)
        at REPLServer.runBound [as eval] (domain.js:433:12)
        at REPLServer.onLine (repl.js:715:10)
        at REPLServer.emit (events.js:215:7)
        at REPLServer.EventEmitter.emit (domain.js:476:20)
        at REPLServer.Interface._onLine (readline.js:316:10)
        at REPLServer.Interface._line (readline.js:693:8)
    undefined
    > 

    被拒绝的替代方案

    一次性隐藏函数

    在此替代方案中,我们引入了诸如 Error.hideFromStackTracesFunction.prototype.hideSource 等新函数,它们将函数永久选择为一种新的隐藏行为。您可以这样使用它:

    function foo() {
      // ...
    }
    
    console.assert(foo.toString().includes("..."));
    
    foo.hideSource();
    
    console.assert(foo.toString() === "function foo() { [ native code ] }");

    此替代方案似乎不如指令好:

    • 难以批量隐藏许多函数。该指令允许一次隐藏整个源文件。
    • 您现在可以隐藏任何人的函数,而不仅仅是您创建和控制的那些。
    • 它是非词法的,因此要求对源代码操作的工具依赖启发式方法来判断函数是否实现了隐藏。
    • 总的来说,它使这成为函数的一个属性,而不是源文本的属性,这似乎是错误的抽象级别,并且更难以推理。

    创建克隆的隐藏函数

    这个想法与上一个类似,不同之处在于 f.hideSource() 返回一个的隐藏函数,而不是修改调用它的函数。然后调用者需要只分发对克隆的引用,而不是原始引用。

    这会遭受许多相同的缺点:

    • 难以批量隐藏许多函数。
    • 它是非词法的。
    • 它使这成为函数的一个属性,而不是源文本的属性。

    并引入了几个新的缺点:

    • “克隆”函数难以规范化和推理。请参阅 关于 toMethod() 的讨论,这是 ES2015 之前的一个提案。它同样制造了带有一次调整的函数克隆,并且由于其复杂性而被 TC39 拒绝。
    • 某些函数难以用其克隆替换。例如,方法需要安装其正确的 [[HomeObject]],这目前是不可能的。

    delete Function.prototype.toString

    过去,人们曾问过引擎是否可以直接检测如下模式

    delete Function.prototype.toString;

    在源文件的早期,并执行适当的优化。

    不幸的是,这在任何多 realm 环境中都行不通:

    delete Function.prototype.toString;
    
    function foo() {
      // ...
    }
    
    const otherGlobal = frames[0];
    console.assert(otherGlobal.Function.prototype.toString.call(foo).includes("..."));

    它也是一个非常笨拙的工具,只能在 realm 级别使用,因此可能只能由应用程序开发者使用。该指令针对的是库开发者;应用程序开发者更容易受到带外解决方案的青睐。

    此后,此提案已扩展为隐藏比 Function.prototype.toString 结果更多的内容,这使得此类解决方案更不可行。

    使用众所周知的符号

    考虑到像 Symbol.isConcatSpreadableSymbol.iterator 这样的 API,人们很想认为相对于某些语言功能改变对象的行为总是应该通过将众所周知的符号安装到对象上来完成。

    这对我们的用例来说效果不佳。这种方法与“一次性隐藏函数”方法一样存在所有问题。此外,任何形式的符号标记都是可逆的:例如

    function foo() { /* ... */ }
    console.assert(foo.toString.includes("..."));
    
    foo[Symbol.hideSource] = true;
    console.assert(!foo.toString.includes("..."));
    
    foo[Symbol.hideSource] = false;
    console.assert(foo.toString.includes("...")); // 糟糕

    这基本上使隐藏变得无效,因此您无法获得所需的封装好处。您可以尝试通过说只设置 true 才有效,而设置 false 或删除属性无效来解决这个问题。

    常见问题

    这是否也应该隐藏函数名称和长度?

    一些用于隐藏源代码文本和堆栈跟踪信息的相同论点也适用于函数的 namelength 属性。该语言已经有一种通过 Object.defineProperty 隐藏这些的机制:

    function foo(a, b, c) { }
    console.assert(foo.name === "foo");
    console.assert(foo.length === 3);
    
    Object.defineProperty(foo, "name", { value: "" });
    Object.defineProperty(foo, "length", { value: 0 });
    console.assert(foo.name === "");
    console.assert(foo.length === 0);

    因为已经可以做到这一点,并且对于 "hide source" 指令的目标用例来说,这不是普遍可取的,所以此行为不包含在指令中。另请参阅 #2 中关于此主题的讨论。

    这如何影响 devtools 或其他检查函数实现细节的方法?

    此提案(如所有 JavaScript 语言提案)仅影响 JavaScript 代码的行为。开发人员工具不在范围内,本提案不打算以任何方式改变它们的行为。因此,通过提案的指令隐藏了实现的函数仍然可以被任何特权 API(例如 devtools 使用的那些)检查,如果该 API 选择暴露此信息。

    具体来说,此提案仅影响两件事:

    • Function.prototype.toString() 产生的 JavaScript 字符串值。
    • 由尚未标准化的属性(如 Error.prototype.stack错误堆栈提案 的其他部分)产生的 JavaScript 值。

    提议的规范文本_不_一定影响以下任何一项:

    • 当开发者执行 console.log(function(){}) 时屏幕上看到的内容(不一定与 JavaScript 暴露的 .toString() 结果相关)。
    • 当开发者执行 console.log(new Error) 时屏幕上看到的内容(不一定与 JavaScript 暴露的 .stack 结果相关)。
    • 当发生未捕获的异常或拒绝时,开发者最终在屏幕上看到的错误堆栈跟踪(不一定与 JavaScript 暴露的 .stack 结果相关)。
    • 当 devtools 用户查看源代码或网络响应面板时屏幕上看到的内容。
    • devtools 用户是否可以在函数内部设置断点或在未捕获异常时暂停。
    • ... 等等。

    也就是说,开发人员工具团队不受任何语言规范的约束;他们对用户体验做出独立决定。他们可以随时决定来自 example.com 的代码在 devtools 中被隐藏,或者被压缩的代码在 devtools 中被隐藏。或者,实际上,他们可能决定通过此提案向 JavaScript 隐藏实现的代码会在 devtools 中被隐藏。那是他们的决定,任何语言规范都无法影响他们的用户体验。我们只能说,提案的拥护者希望 devtools 继续使代码尽可能可内省。

    最终每个人都会到处使用它吗?

    我们不这么认为。与通常使您的代码更好的 "use strict" 不同,"hide source" 是专为需要极高封装和重构自由度的函数作者而设计的专门机制。

    封装通常是一个滑动比例。一些库作者满足于下划线前缀的属性,并声明如果消费者依赖此类属性,他们可能会被破坏。一些作者更进一步,使用符号键控属性来创建减速带。而另一些则使用 WeakMap 或私有字段,以确保消费者_不能_依赖此类实现细节,或探查存储在类中的秘密值。

    此提案属于后一种情况。它确保消费者不能使用 Function.prototype.toStringError.prototype.stack 来创建敌视重构的依赖项,或暴露秘密值。我们认为这些情况足够重要,值得提出提案,但不足以普遍到令人担心每个 JavaScript 文件中都会撒满指令序言。

    最后,尽管历史迹象表明此指令可能提供内存节省(从而被所有关心内存的人即所有人采用),但这些迹象已被证明是错误的。有关更多信息,请参阅附录。即使情况发生变化,我们认为附录中探索的带外机制将是实现这些内存节省的更有吸引力的机制,使此提案专注于更专门的封装情况。

    "sensitive" 指令未来可能会有哪些扩展方式?

    "sensitive" 指令提供了一种声明式的、文本界定的选择加入,其最终目标是使所有技能水平的 JavaScript 程序员更有可能编写安全敏感代码而不会引入意外的保密性违规。为了理解边界,我们必须定义程序的哪些方面得到了保密保证,以及程序的哪些部分被视为“在”保密边界“内”。保密性提供用于

    1. 保密区域的源代码文本
    2. 保密区域的局部绑定
      1. 例如,如果 Function.prototype.environment 提案 进入语言,它不适用于标记为 "sensitive" 的函数
    3. 如果保密区域是一个函数,则该函数的调用行为
      1. 实际上,这意味着该函数不会出现在堆栈反射机制中

    当且仅当程序代码在文本上位于保密区域内时,它才被视为在保密边界内。通过直接在保密边界内调用 eval 评估的代码也被视为在保密边界内。

    请注意,由于语言设计/可用性约束,对保密区域保密边界之外的代码施加的限制也可能适用于其保密边界内的代码。例如,标记为 "sensitive" 的函数不会出现在堆栈帧中,无论执行反射的代码是否存在于适当的保密边界内。

    为什么没有 "preserve source" 指令?

    可能存在这样的情况:一个函数需要在其包含 "hide source" 指令的另一个函数的词法闭包内声明,但不应继承其祖先的实现隐藏行为。大多数代码不应该需要这个,因为函数声明可以提取到一个函数,并且它关心的绑定可以传入。但对于直接调用 eval 与实现隐藏指令禁用的各种反射一起使用,并且无法对正在评估的字符串内容做出假设的情况,无法在文本上提取函数声明。

    这是一个小众用例,可能会在以后的提案中评估是否将其包含在语言中。

    附录:带外的内存节省开关

    历史上,在这个领域有很多想法、动机和提案。在 2018 年 1 月的 TC39 会议上,我们意识到有两个相关的提案:

    *(此提案)一种封装机制,与源代码文本带内应用,特别适合库。

    • 一种内存节省机制,与源代码文本带外应用,特别适合应用程序。

    后者的想法是,为了让 Function.prototype.toString 返回预期结果,引擎需要在内存中保存大量数据:即应用程序的原始源代码文本及其所有依赖项。希望通过允许应用程序作者全局关闭源代码文本存储,应用程序能够消耗更少的内存。

    由于应用程序由宿主引导,这很可能通过特定于主机的机制来完成,例如 HTTP 头、<meta> 标签或 Node.js 命令行标志。然后,宿主将通过 HostHasSourceTextAvailable 与 JavaScript 语言规范联系起来。有关此空间的探索,请参阅本文档的先前修订版

    2018 年 1 月 TC39 会议的结论是拆分这些工作,将本提案的重点放在带内机制上,并分别与各个主机环境一起追求带外机制。然而,不久之后与 JavaScript 引擎实现者的讨论揭示了内存节省机制的基本前提是有缺陷的。在保留源代码文本以进行惰性编译的引擎中,从用户土地代码隐藏源代码文本的指令实际上不会节省内存!

    因此,为 Function.prototype.toString 开发带外内存节省开关的工作尚未取得进展。如果引擎改变其惰性编译技术,它可能会重新开始。在这种情况下,本附录将被更新以指导您了解这些努力。