Reference
System
Edge Python reaches outside its sandbox through four system modules.
| Module | Reaches |
|---|---|
fs | The files of the project |
net | The network |
time | The clock |
secret | The values a host keeps for the program |
They ship inside Edge Python and need no imports entry. A package can import one only when the root edge.json grants it.
import fs
import net
import time
import secretPermissions
The root edge.json grants each package it imports. Each package passes grants on to the ones it imports. A permissions section maps each holder to a list of entries, each written module:scope.
{
"imports": { "analytics": "1.2.0" },
"permissions": {
"main": ["net:api.example.com", "time:wall", "time:zone"],
"analytics": ["net:api.telemetry.com", "time:monotonic"]
}
}| Holder | Who holds its entries |
|---|---|
main | The code of the package itself. For the root, every module under the root edge.json |
all | main and every package it imports |
| An import key | The package that key imports |
eval | Nobody. It caps what a bundle may grant in an eval group |
| Entry | Allows |
|---|---|
net:<host> | request and connect to that exact host, on any port and path |
net:<host>/<prefix> | The same host, but only at <prefix> and the paths under it |
time:wall | now() |
time:monotonic | now('monotonic') |
time:zone | zone() |
secret:<NAME> | read('<NAME>'), that one value and no other |
fs:./<folder> | read and list under that folder of the project |
fs:. | read and list anywhere in the project |
Each scope follows a few rules.
- A
nethost is a lowercase name or an IPv4 written asa.b.c.d. It carries no scheme and no port. - IPv6 is not supported. The policy of a browser frame cannot name one.
- A secret name is uppercase letters, digits and underscores. It never starts with a digit.
- An
fsfolder is written from the rootedge.json.fs:./shopreachesshop/Gemfileand nevershopping/. - An entry without a scope, such as
"net", lets the package import the module and reach nothing.
Path prefixes
net:api.example.com/v2 reaches /v2 and /v2/items and no other path. A path counts as under the prefix only if it stays there however a server reads it.
- It may be decoded once or twice.
- A backslash may be taken for a slash.
- A
;parameter may be dropped.
Under that grant /v2/..%2fadmin and /v2/..;/admin are refused. /v2/group%2Fproject reaches.
Who holds what
A package is what an import key brings in, the nearest edge.json above its files.
- A package holds what its importer writes under its key, joined with what the importer writes for
all. - An importer other than the root passes on only what it holds itself.
- The
namea manifest gives itself never counts. No package can pose as another. - Code reached by a relative import belongs to the package that imports it, even under an
edge.jsonof its own. - A package two importers share loads once for each. Each copy holds only what its own importer passes it.
- Code outside any package holds nothing.
What a dependency asks for and passes on
A dependency writes in its own edge.json what its code needs under main. It writes what each package it imports needs under that import key. It passes on only what it holds, and its importer has to give it everything it lists.
A generic client such as http asks for "net" alone. The package that imports it passes the hosts.
{ "name": "analytics", "imports": { "http": "0.2.0" }, "permissions": { "main": ["time:monotonic"], "http": ["net:api.telemetry.com"] } }The CLI holds the tree to these asks at every step.
edge addprints what each new package asks for.edge lockwalks the whole tree, each package through its ownedge.lock. It writes nothing until the root gives each package it imports everything that package lists, and it names each one that misses something.- A package that passes an import less than it asks fails the lock the same way.
edge build,edge runandedge testrun the same check before anything compiles. An edit after the last lock cannot slip past it.
error: edge.json does not grant what these packages ask for
analytics 1.2.0 net:api.telemetry.com, time:monotonic
help: grant them under "permissions" in edge.jsonNo import may be named all, main or eval. The package it brings in would answer to that holder. The registry refuses to publish a package named any of them.
Where a grant is checked
Importing a system module the package holds no entry for fails at compile time.
'analytics' imports net, which edge.json does not grant itEvery call checks its scope again, since a url is only known once the program runs. A call outside the grant raises PermissionError in the coroutine that made it. PermissionError is a subclass of OSError.
import net
try:
net.request("GET", "https://evil.example/")
except PermissionError as e:
print(e) # 'main' has no net:evil.example, edge.json grants it net:api.example.comA request or socket belongs to the package that opened it. Another package cannot read it. A malformed section stops the run before anything compiles, naming the holder and the entry.
time
| Call | Needs | Returns |
|---|---|---|
now(), now('wall') | time:wall | Nanoseconds since the Unix epoch |
now('monotonic') | time:monotonic | Nanoseconds from an arbitrary start, never going back |
zone() | time:zone | [name, offset], the IANA zone and its offset from UTC in seconds |
Without any time scope a run uses a virtual clock. sleep and with_timeout pass at once and in order, and a host call takes no time. What a program prints then never depends on when it runs.
Any time scope turns the wall clock on for the whole run.
net
| Call | Returns |
|---|---|
request(method, url, headers, body) | An id at once. The request goes out in the background |
response(id) | [status, headers] once the head arrived |
read(id) | The next chunk of the body or the next message of a socket. None once it ended |
connect(url) | A socket id once the WebSocket is open |
send(id, data) | Sends bytes or a str on a socket |
close(id) | Aborts a request or closes a socket |
headers is a list of [name, value] pairs or a dict. body is bytes, a str or None. A failure to connect or read raises OSError from the call that meets it.
import net
r = net.request("GET", "https://api.example.com/items", [["accept", "application/json"]], None)
status, headers = net.response(r)
body = b""
chunk = net.read(r)
while chunk is not None:
body += chunk
chunk = net.read(r)URL rules
The check and the request always read the same host and exactly one path. The path is resolved before the check and sent as resolved.
| Part | Rule |
|---|---|
| Host | It comes right after the scheme, with no user before it. Anything else raises ValueError |
| Characters | No backslash, space or control character anywhere, or ValueError. An @ later in the path stays, as in https://edgepython.com/@dylan |
| Non-ASCII host | Raises ValueError. Only its punycode form reads the same everywhere |
| Numeric host | A host ending in a number raises ValueError unless it is written a.b.c.d. A browser reads 0x7f.1 or 2130706433 as an IPv4 |
| Dot segments | A . or .. segment is applied however it is spelled. /v2/%2e%2e/admin is /admin, and a path prefix cannot be climbed out of |
| Other text | Text outside the unreserved set crosses as its escaped UTF-8 bytes. /Español is sent as /Espa%C3%B1ol |
| Fragment | Dropped. It never leaves the host |
| Redirects | No host follows one. A 301, 302, 303, 307 or 308 raises OSError, and the new address goes through its own request and its own check |
| Headers | One that fetch keeps for the host raises ValueError, such as Host, Cookie, Origin or any Sec- or Proxy- header |
In a browser the request is a fetch from the Web Worker, and CORS applies on top of the grant. The CLI runs the same calls in SpiderMonkey, with its own HTTP and WebSocket clients underneath. A program answers the same with and without --web.
secret
| Call | Needs | Returns |
|---|---|---|
read(name) | secret:<name> | The value the host keeps under that name, as a str |
A name the package does not hold raises PermissionError. A name it holds but the host keeps no value for raises OSError.
Nothing lists the names a host keeps. A program reads only the values it was granted and asks for by name.
- The CLI reads
EDGE_SECRET_<name>from its environment at the moment of the call. A program never reaches a variable it was not granted, whatever else the environment holds. - With
--webthe CLI hands the page only the values asecretentry names. - A page that embeds the engine passes them to
createWorkeras secrets.
import secret
token = secret.read("GITHUB_TOKEN")EDGE_SECRET_GITHUB_TOKEN=ghp_example edge run main.pyfs
| Call | Needs | Returns |
|---|---|---|
read(path) | fs: a folder holding path | The text of that file, read as UTF-8, as a str |
list(dir='.') | fs: a folder holding dir | Every file under dir, as paths from the root edge.json, in order |
A path is written from the root edge.json, such as shop/app/product.rb. Nothing writes.
| Case | Raises |
|---|---|
A path with .., a leading / or a name starting with a dot | PermissionError |
| A path outside every folder the package holds | PermissionError |
| A file that is missing, is not UTF-8 or is over 10 MB | OSError |
| A list of more than 10,000 files | OSError |
Each host reads the files its own way. Both read the same files.
- The CLI reads the disk, or the bundle a run came in. It never follows a link, even one pointing inside the project.
- A page reads the files beside its program through the same reader its imports use.
- HTTP cannot list a folder. A page lists files from
edge.files, the listedge build --webwrites andedge serveand--webanswer. - A page reads only the paths that list names.
- A page that embeds the engine serves
edge.filesbeside itsedge.json, a JSON list of every pathfsmay read.
{ "permissions": { "main": ["fs:./shop"] } }import fs
for path in fs.list("shop"):
print(path)
code = fs.read("shop/app/models/product.rb")batch
Every system module also takes batch(calls). calls is a list of [name, *args] lists answered in one crossing, and the results come back in the same order.
- A call that waits holds the batch until every one settles.
- The first failure raises in the coroutine that made the batch.
- Each call still checks its own scope.
import net
import time
now, zone = time.batch([["now"], ["zone"]])
ids = net.batch([["request", "GET", url] for url in ["https://api.example.com/a", "https://api.example.com/b"]])
heads = net.batch([["response", i] for i in ids])Each crossing between the program and the host pays to decode the arguments and encode the answer. A batch pays it once for all of its calls.
From a plugin
A .wasm plugin reaches these calls through the Sys op of the ABI.
In a page
A page passes grants, secrets and a trace switch to createWorker, described in JavaScript.