Документация
Песочница и capabilities
Приложение на Rust — это нативный код, скомпилированный в поставляющее его приложение так же, как и остальная его часть, и он работает с его уровнем доверия. Его вызовы сервисов NSH (fetch, изображения, веб-представления, строка состояния) проходят те же нативные проверки, что и вызовы из TSX, в рамках того, что разрешает хост. TSX и JavaScript, которые выполняет приложение, собственные или из загружаемого им подписанного пакета, работают в виртуальной машине QuickJS на каждое представление, а всё, что они могут делать за пределами этой VM, проходит через нативный рантайм NSH. Благодаря этому можно запускать код, который вы не писали, например сторонние мини-приложения или загружаемые обновления, по правилам, которые контролирует хост-приложение:
- Лимиты ограничивают память, время и нативные ресурсы, доступные одному приложению.
- Усиление защиты в релизных сборках убирает все способы превратить строки в код.
- Capabilities перечисляют, до чего приложение может дотянуться: с каких хостов загружать данные, какие страницы может показывать веб-представление, может ли оно менять строку состояния. Приложение объявляет их сами; хост-приложение может выдать меньше.
Бандл содержит только код приложения: реактивный граф, рендерер и каждый импортируемый приложением модуль нативные. Поэтому ничто в JS не является доверенным, а каждый лимит и каждая проверка применяются в Rust; в JS нет кода, который приложение могло бы подменить, чтобы обойти их.
#Лимиты
Лимиты ограничивают VM: они действуют для TSX и JavaScript. Приложение, встроенное в другое приложение, работает в пределах лимитов, которые определяет его хост. Автономное приложение (упакованное с помощью nsh package, включая веб, или запущенное с помощью nsh run) — это ваш собственный код, как и нативное приложение: оно использует память и время, которые предоставляет устройство, а стек интерпретатора в 4096 КБ превращает глубокую рекурсию в RangeError, которую приложение может перехватить.
У встроенного приложения действуют следующие лимиты:
| Лимит | По умолчанию | Что происходит при достижении |
|---|---|---|
| Куча JS | 256 МБ | VM останавливается. |
| Стек интерпретатора | 4096 КБ (это и максимум) | Глубокая рекурсия выбрасывает RangeError, которую приложение может перехватить. |
| Время вычисления бандла | 10 с | Вычисление прерывается. |
| Время любого другого вызова в JS | 2 с | Вызов прерывается; приложение продолжает работу. |
| Хранимые узлы | 200 000 | VM останавливается. |
| Изменения за один раз (операции в очереди) | 8 000 000 значений | VM останавливается. |
| Операции в одной отрисовке canvas | 1 000 000 | VM останавливается. |
Время ограничивается на каждый вызов, а не на приложение. «Вызов» — это одно действие, которое рантайм просит выполнить JS: обработать одно событие (включая вызванный им рендеринг), привязать одну строку списка, выполнить один таймер, выполнить колбэки requestAnimationFrame одного кадра, опустошить очередь микрозадач. Долго работающее приложение никогда не накапливает время в счёт лимита; прерывается только отдельный вызов, не вернувшийся вовремя. Вызовы, вложенные в другой вызов, разделяют бюджет внешнего.
Прерванный вызов завершается исключением, которое не может поглотить ни один catch, а об ошибке сообщается с указанием превышенного бюджета:
the script was interrupted: a call into JS exceeded its budget of 2000 ms
VM продолжает работать: следующее событие, таймер или кадр выполняются как обычно.
Нехватка памяти или превышение квоты останавливает VM. Об ошибке сообщается один раз (обработчику ошибок хост-приложения, а в сборках для разработки ещё и поверх приложения), после этого JS не выполняется, а представления остаются в прежнем состоянии. Квоты на узлы и операции существуют потому, что нативные представления и очередь изменений живут в нативной памяти, на которую лимит кучи JS не распространяется: без них приложение могло бы исчерпать память устройства через UI.
Хост-приложения задают лимиты для каждого представления: NshAppView.limits на iOS, NshView.setLimits на Android, AppOptions::limits / AppView::set_limits в приложении GPUI. Они применяются со следующей загрузки.
Чтобы увидеть в разработке, как TSX-приложение работает в рамках лимитов хост-приложения, запустите его с --limits: nsh run App.tsx --limits использует значения по умолчанию, указанные выше, а nsh run --limits memory=128MB,call=500ms задаёт эти два лимита так, как это делало бы хост-приложение, остальные при этом сохраняют значения по умолчанию.
#Усиление защиты
Релизные сборки (всё, что не загружается с dev-сервера) усиливаются при загрузке:
-
evalудаляется, а конструкторыFunction,AsyncFunction,GeneratorFunctionиAsyncGeneratorFunctionвыбрасывают исключение. NSH не выполняет код, созданный из строк:TypeError: the Function constructor is disabled: nsh runs no code from strings (development builds allow it) -
Прототипы, общие для всех модулей (
Object,Array,Function,String,Number,Promise,Map, типизированные массивы и остальные), заморожены, поэтому один модуль (или одна библиотека) не может изменитьArray.prototypeдля всех остальных.Error.prototypeостаётся доступным для записи, чтобы подклассы ошибок могли задаватьthis.name.
Заморозка прототипов обычно ломает распространённый паттерн, «override mistake»: присваивание Sub.prototype.constructor = Sub или obj.toString = … выбрасывает исключение, если унаследованное свойство заморожено. NSH превращает наиболее часто переопределяемые члены (constructor, toString, valueOf, toJSON, …) в аксессоры, сеттер которых определяет свойство на объекте, которому присваивают, поэтому такой код (и библиотеки вроде axios, которые его используют) продолжает работать.
Бандлы, которые отдают nsh dev / nsh run (с --release или без него), не усиливаются, поэтому их можно отлаживать с помощью eval. Проверяйте поведение релиза с помощью nsh test (он включает усиление) или упакованной сборки, прежде чем поставлять код, зависящий от динамического кода.
#Capabilities
Приложение объявляет, до чего оно может дотянуться за пределами своей VM, в nsh.json:
{
"name": "Quotes",
"id": "com.example.quotes",
"version": "1.0.0",
"build": 3,
"capabilities": {
"network": {
"hosts": ["api.example.com", "*.cdn.example.com"],
"http": [{ "host": "quotes.example.com", "methods": ["GET"], "pathPrefix": "/v1/" }],
"sockets": ["feed.example.com:7000"]
},
"webview": { "hosts": ["*.example.com"], "input": true },
"statusBar": true,
"clipboard": true,
"picker": true,
"speech": true,
"host": ["getUserInfo", "bridge.*"],
"openUrl": { "schemes": ["https", "mailto"] }
}
}
Приложению без "capabilities" разрешено всё: автономное приложение принадлежит его разработчику, и ничего не ограничивается. Сырые TCP-сокеты выдаются по имени в каждом приложении через network.sockets, а функции хоста — через host. Приложению с "capabilities" разрешено только то, что в нём перечислено: всё остальное запрещено. Неизвестные ключи — это ошибки сборки, поэтому опечатка обнаруживается при сборке, а не превращается в отсутствующее разрешение во время выполнения. Подписанный пакет от более новой версии NSH может объявлять capabilities, которых более старый рантайм не знает: в нём они не выдаются.
#network
Охватывает fetch (включая каждый редирект), WebSocket, удалённые изображения (<Image>, placeholder, изображения canvas, Image.getSize / Image.prefetch), источники видео и сырые TCP-сокеты (net.connect).
| Ключ | Тип | Описание |
|---|---|---|
hosts | string[] | Хосты, к которым приложение может обращаться любым методом и по любой схеме (http, https, ws, wss). |
http | object[] | Более точные разрешения: хост, суженный любым из ключей ниже. |
http[].host | string | Хост или шаблон хоста (обязательно). |
http[].methods | string[] | Разрешённые методы, например ["GET", "HEAD"]. Если не указано: любые. |
http[].schemes | string[] | Разрешённые схемы (scheme: одна, в виде строки). Если не указано: любая из http, https, ws, wss. |
http[].port | number | Порт; у URL без порта используется порт по умолчанию для его схемы (443 для https). Если не указано: любой. |
http[].paths | string[] | Точные пути, например ["/v1/quote"] (запрос не входит в путь). |
http[].pathPrefixes | string[] | Префиксы путей, охватывающие целые сегменты: /v1 охватывает /v1 и /v1/quote, /v1/ — всё, что следует за ним. pathPrefix (один, в виде строки) и path_prefixes читаются одинаково. |
sockets | string[] | Адреса назначения для сырых TCP-соединений через net.connect, каждый — точная пара "host:port" ("[::1]:9000" для IPv6). |
Правило http охватывает все пути, если в нём не указаны paths и pathPrefixes, а если указаны оба — путь, подходящий под любой из них.
Шаблоны хостов: * (любой хост), *.example.com (любой поддомен example.com, но не сам example.com) или точное имя хоста. При сопоставлении регистр и порт игнорируются. Хост берётся после последнего @, поэтому https://api.example.com@evil.io/ проверяется как evil.io.
Рукопожатие WebSocket — это HTTP GET: ws:// проверяется как http://, а wss:// — как https://, поэтому разрешения в hosts и http, допускающие GET для этого URL, допускают и WebSocket. Сырой сокет обходит все HTTP-правила, поэтому sockets перечисляет каждый хост и порт точно, и разрешает их только sockets.
URI asset: (собственные файлы приложения) разрешены всегда; изображения и видео могут также использовать URI data: и относительные пути.
#webview
| Ключ | Тип | Описание |
|---|---|---|
hosts | string[] | Хосты, которые может показывать веб-представление и на которые оно может переходить (http и https). Без webview приложение вообще не может показывать веб-страницы. |
input | boolean | Ref веб-представления может передавать своей странице доверенный ввод (sendInput). Никогда не выдаётся, если не объявлено, в том числе для приложения без "capabilities". |
Переходы верхнего уровня внутри веб-представления тоже проверяются делегатом навигации платформы до загрузки. Собственные подресурсы страницы (её скрипты, изображения, XHR) — дело веб-контента и не проверяются. about:blank и встроенный HTML (html) требуют лишь того, чтобы веб-представления были разрешены вообще: "webview": { "hosts": [] } допускает их, не допуская ни одной удалённой страницы.
input отделён от hosts, потому что доверенный ввод несёт пользовательскую активацию, которой injectJavaScript никогда не получает: тогда страница может открывать окна, записывать в буфер обмена, открывать системную панель выбора файлов, запускать WebAuthn или запросы разрешений и нажимать любые кнопки, проверяющие isTrusted. Без него ядро отбрасывает sendInput и записывает в лог, какой ключ нужно добавить.
#statusBar
"statusBar": true позволяет приложению менять строку состояния (statusBar страниц и диалогов в nsh/base и значение по умолчанию "statusBar" у nsh.json).
#clipboard
"clipboard": true позволяет приложению читать и записывать системный буфер обмена (Clipboard). Копирование из меню редактирования выделяемого текста — собственное действие пользователя и не требует разрешения.
#picker
"picker": true позволяет приложению показывать системный выбор фотографий (Picker): пользователь сам выбирает, что попадёт в приложение.
#speech
"speech": true позволяет приложению распознавать речь пользователя (Speech): микрофон и системный распознаватель речи, транскрипция которого попадает в приложение. Система по-прежнему один раз спрашивает пользователя.
#openUrl
Позволяет приложению открывать URL за его пределами (openUrl, ссылки TextView с links="open"): "openUrl": true для всех схем, "openUrl": { "schemes": ["https", "mailto"] } для перечисленных схем. URL, схема которого не разрешена, отклоняется с ERR_ACCESS_DENIED (permission "openUrl", resource — URL). URL, которые выполняли бы код или обращались бы к собственным данным приложения (javascript:, data:, file:, …), никогда не открываются, независимо от разрешения.
#Storage
Каждое приложение может хранить свои данные на устройстве с помощью Storage без какого-либо разрешения: хранилище содержит только данные этого приложения, а место хранения определяет встраивающее приложение.
#host
host перечисляет функции приложения, встраивающего NSH, которые может вызывать host.call(name, args) из nsh: точные имена ("getUserInfo") или префиксы, оканчивающиеся на * ("bridge.*" соответствует bridge.call и bridge.share). "host": true разрешает все функции. Функция, которой нет в списке, отклоняется, в том числе в приложении без "capabilities": функции хоста никогда не выдаются по умолчанию, а сборка приложения, импортирующего host из nsh без host, предупреждает, что каждый вызов будет отклонён. Имена содержат от 1 до 128 символов из A–Z, a–z, 0–9, _, ., : и -, без ..; вызов любого другого имени отклоняется до чтения разрешений. Оканчивайте префикс разделителем ("bridge.*"): "bridge*" соответствует и bridgeAdmin. События хоста (host.on(event, listener)) доходят до слушателей приложения в том виде, в каком их отправляет хост.
import { host } from "nsh";
if (host.available()) {
const user = await host.call("getUserInfo");
const stop = host.on("loginChanged", (payload) => console.log(payload));
stop();
}
host.available() равно true на платформах, встраивающих хост; на остальных host.call отклоняется с code "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM".
#Как выглядит отказ
| Запрос | Результат |
|---|---|
fetch | Промис отклоняется с TypeError, как при любом неудачном запросе; его code равно ERR_ACCESS_DENIED, permission "network" и resource URL (запроса или редиректа); запрос никогда не доходит до сети. |
Image.getSize, Image.prefetch | Промис отклоняется с ERR_ACCESS_DENIED, permission "network" и resource URL; изображение не запрашивается. |
Удалённое изображение, источник видео, источник web view или html | Ничего не показывается, а onError элемента сообщает об этом с code "ERR_ACCESS_DENIED". Переход web view на хост, к которому нет доступа, отменяется и сообщается так же. |
| Изменение Status bar, изображение на canvas | Ничего не меняется и не рисуется; отказ записывается в лог. |
| Clipboard | Каждая функция Clipboard отклоняется с ERR_ACCESS_DENIED, permission "clipboard" и resource ""; системный буфер обмена никогда не затрагивается. |
| Picker | pickImages ничего не показывает и отклоняется с ERR_ACCESS_DENIED, permission "picker" и resource "". |
| Speech | Speech.start выбрасывает ERR_ACCESS_DENIED, permission "speech" и resource ""; микрофон остаётся выключенным. |
openUrl | Промис отклоняется с ERR_ACCESS_DENIED, permission "openUrl" и resource URL; ничего не открывается. |
| Вызов хоста | host.call отклоняется с ERR_ACCESS_DENIED, permission "host" и resource имя функции (host.post выбрасывает его); вызов не доходит до хоста. |
Файлы (fs/promises) | Промис отклоняется с ERR_ACCESS_DENIED, permission "fs" и resource путь; диск никогда не затрагивается. |
Процессы (process) | run отклоняется, а spawn и exit выбрасывают ошибку с ERR_ACCESS_DENIED, permission "process" и resource команду ("exit" для exit); дочерний процесс не запускается. |
TCP-сокет (net.connect) | Промис отклоняется с ERR_ACCESS_DENIED, permission "network" и resource "host:port", до любого DNS-запроса. |
| WebSocket | connect модуля websocket отклоняется с ERR_ACCESS_DENIED, permission "network" и resource URL; глобальный WebSocket вызывает error, а затем close (код 1006), как при неудачном соединении. |
Ошибки стандартных модулей несут code "ERR_ACCESS_DENIED", поэтому приложение отличает отказ от неудачной операции, а их permission и resource называют capability и то, что было запрошено. Модули nsh отказывают так же: их промисы отклоняются с ERR_ACCESS_DENIED, permission и resource (fetch с TypeError, классом любого неудачного запроса).
import { Clipboard } from "nsh";
try {
await Clipboard.writeText(link);
} catch (error) {
if (error.code !== "ERR_ACCESS_DENIED") throw error;
console.warn(`${error.permission} is not granted`); // "clipboard"
}
Компоненты на Rust получают те же отказы в терминах Rust: отклонённое чтение clipboard возвращает то же, что и пустой clipboard, отклонённый выбор — ни одного файла, отклонённый open_url false, а отклонённый fetch возвращает Err с сообщением.
Каждый отказ записывается в лог один раз с указанием, какой ключ нужно добавить:
fetch GET https://evil.example.org/x is not granted: add `evil.example.org` to capabilities.network in nsh.json
Редиректы выполняет NSH, а не HTTP-стек платформы, поэтому каждый переход проверяется на соответствие capabilities. (Потоковое тело запроса не отправляется дважды, поэтому редирект 307 / 308 при потоковой выгрузке завершается ошибкой.)
#Куда попадает декларация
Декларация следует за приложением, куда бы оно ни попало:
nsh buildиnsh packageзаписывают её рядом с бандлом какcapabilities.json;nsh publishпомещает её в заголовок подписанного пакета, и она защищена подписью;nsh devотправляет её с каждой сборкой, поэтому в разработке действуют те же правила. Изменениеcapabilitiesперезагружает приложение вместо горячего патча.
#Гранты: что разрешает хост-приложение
Хост-приложение, встраивающее NSH, может выдать не больше некоторых capabilities в той же JSON-форме:
// iOS, in Rust (nsh_platform_ios::AppView)
let grant = nsh_engine::Capabilities::from_json(&serde_json::json!({
"network": { "hosts": ["api.example.com"] }
}))?;
app.set_granted_capabilities(Some(grant));
// iOS
try app.grantCapabilities(#"{ "network": { "hosts": ["api.example.com"] } }"#)
// Android
nsh.setGrantedCapabilities("{ \"network\": { \"hosts\": [\"api.example.com\"] } }");
Запрос должен быть разрешён и декларацией приложения, и грантом хоста. При приведённом выше гранте приложение, которое объявляет "hosts": ["*"], всё равно сможет обращаться только к api.example.com и не сможет показывать веб-страницы или менять Status bar. Без гранта (nil / null, по умолчанию) действует то, что объявляет каждое приложение.
Гранты применяются со следующей загрузки. Типичная схема для сторонних мини-приложений: выдать каждому только его собственный API-хост, запускать его из подписанного пакета, декларацию которого вы проверили, и оставить лимиты по умолчанию или снизить их.
#Итог
| Уровень | Кто обеспечивает | Кто задаёт |
|---|---|---|
| Лимиты (heap, stack, время, узлы) | runtime, для каждой VM | хост-приложение для каждого view (встраиваемые приложения) |
| Hardening | runtime, при загрузке | всегда включён вне разработки |
| Capabilities | runtime (fetch, изображения, видео, web view, Status bar, файлы, процессы, сокеты) | приложение (nsh.json) и хост-приложение (грант) |
| Целостность кода | проверка подписи | nsh publish; доверенные корни хост-приложения |