Open an FTP or FTPS server as a folder in Memona

This commit is contained in:
Danny Kim
2026-09-25 11:57:46 +09:00
commit 95a2b1a086
14 changed files with 1359 additions and 0 deletions
+215
View File
@@ -0,0 +1,215 @@
package memona:filesystem@0.1.0;
/// Host services scoped to the current connection. No ambient filesystem,
/// environment, sockets, credentials, or network permissions are inherited.
interface host {
/// Stable failures without backend messages or credential-bearing URLs.
enum host-error { denied, unavailable, invalid-input, cancelled, internal }
/// Severity of a diagnostic event. The host may discard message contents.
enum log-level { debug, info, warn, error }
/// Every method the host will send. A verb outside this list cannot be
/// spelled, so no plugin can smuggle one past the transport rules.
enum method {
get, head, post, put, patch, delete,
propfind, proppatch, mkcol, copy, move, lock, unlock, report,
}
/// An HTTP header. Host-controlled transport headers cannot be overridden.
record header { name: string, value: string }
/// Request head. At most 64 headers of 8 KiB each; the body is streamed.
record request { method: method, url: string, headers: list<header> }
/// Response head. The body is read from the exchange that produced it.
record response { status: u16, headers: list<header> }
/// One HTTP request and its response, streamed in bounded chunks so a
/// transfer is never held whole in memory. A body of any size crosses in
/// pieces, which is what multipart uploads of at least 5 MiB require.
/// Dropping the handle abandons the request.
resource exchange {
/// Appends at most 1,048,576 bytes to the request body. Every push
/// must land before `respond`.
push: func(data: list<u8>) -> result<_, host-error>;
/// Ends the request body and waits for the response head. Calling it
/// twice is invalid-input.
respond: func() -> result<response, host-error>;
/// Reads at most 1,048,576 bytes of the response body, after
/// `respond`. An empty result means the body is complete.
pull: func(limit: u32) -> result<list<u8>, host-error>;
}
/// Begins a request to a host both declared by the package and granted on
/// the connection. Each redirect and DNS result is checked, and DNS is
/// pinned. Private/LAN addresses additionally require the private-network
/// grant. Cross-origin redirects remove request headers; HTTPS cannot
/// downgrade. The host bounds total bytes per exchange, not buffer size.
http: func(request: request) -> result<exchange, host-error>;
/// One open TCP connection. Dropping the handle closes the socket, and the
/// host closes every socket when the connection is disposed.
resource tcp {
/// Reads at most `limit` bytes, between 1 and 1,048,576. An empty
/// result means the peer closed its side, not that data is pending.
read: func(limit: u32) -> result<list<u8>, host-error>;
/// Writes at most 1,048,576 bytes and returns how many were accepted.
write: func(data: list<u8>) -> result<u32, host-error>;
}
/// Opens a socket to a host and port both declared by the package and
/// granted on the connection. The host resolves and pins DNS, applies the
/// same private-address rule as `http`, and starts TLS when asked. Ports
/// negotiated at runtime, such as an FTP data channel, still need a grant.
connect: func(host: string, port: u16, tls: bool) -> result<tcp, host-error>;
/// One datagram socket connected to a single granted peer.
resource udp {
/// Sends one datagram of at most 65,507 bytes.
send: func(data: list<u8>) -> result<u32, host-error>;
/// Receives one datagram of at most `limit` bytes. An empty result
/// means none arrived inside the host's wait, not that the peer left.
receive: func(limit: u32) -> result<list<u8>, host-error>;
}
/// Connects a datagram socket under the same declaration and grant rules
/// as `connect`, for the few filesystem protocols that still need one.
bind: func(host: string, port: u16) -> result<udp, host-error>;
/// A configuration value, typed as the package's manifest declared it.
/// A secret is never logged by the host and never leaves the connection.
variant setting { text(string), secret(string), toggle(bool) }
/// Reads configuration or a session-only credential for this connection.
config: func(key: string) -> option<setting>;
/// Reports that the current access token was rejected. The host refreshes
/// it once; a second rejection surfaces to the caller as denied.
invalidate-token: func();
/// Emits a diagnostic event. Never include secrets or private document data.
log: func(level: log-level, message: string);
}
/// Filesystem operations for one connection. Paths are absolute within its
/// root, starting with `/`; they are never local OS paths or resource URIs.
/// Dot segments, backslashes, percent escapes, colons, and controls are invalid.
/// Every invocation is bounded by host fuel, memory, time, and cancellation.
interface provider {
use host.{setting};
/// Stable operation failures. A mutation interrupted after dispatch can
/// report outcome-unknown: inspect remote state before deciding to retry.
enum error-code {
not-found, denied, unsupported, conflict, unavailable, invalid-input,
cancelled, outcome-unknown, internal
}
/// Anything that is neither a file nor a directory, such as a link, is
/// reported as other rather than flattened into one of the two.
enum entry-kind { file, directory, other }
/// An opaque version token, at most 4096 UTF-8 bytes.
type revision = string;
/// An opaque backend identity, at most 4096 UTF-8 bytes. The host never
/// parses, orders, or shows one, and never keeps one past the connection,
/// because a backend may reuse it.
type resource-id = string;
/// How one resource is named. A provider that hands out ids must accept
/// them back; one that does not may reject every id as invalid-input.
variant locator { path(string), id(resource-id) }
/// Optional operations. False capabilities must return unsupported.
/// Conditional-write promises atomic create-only and revision comparisons,
/// and cannot be combined with staged-write, where nothing to compare
/// exists until a publish lands. Virtual-directories says a directory is
/// only ever a prefix of something stored, as in a flat object store: the
/// host then never offers to create an empty one, warns that removing the
/// last child removes the folder, and does not call a vanished folder an
/// error. Such a provider may still accept mkdir by writing a marker, and
/// must answer unsupported when asked to rename a directory.
record capabilities {
write: bool, mkdir: bool, rename: bool, remove: bool,
conditional-write: bool, watch: bool,
staged-write: bool, publishable: bool,
virtual-directories: bool,
}
/// Byte length, an opaque revision, and a backend identity, each if known.
record file-info {
kind: entry-kind,
size: u64,
revision: option<revision>,
id: option<resource-id>,
}
/// One child name, without slashes, dot segments, or control characters.
record entry { name: string, info: file-info }
/// At most the requested number of entries. A cursor (maximum 4096 bytes)
/// identifies another page; absence means enumeration is complete.
record list-page { entries: list<entry>, cursor: option<string> }
/// Atomic preconditions: absent file, exact opaque revision, or unconditional
/// replacement. Providers must never simulate comparisons non-atomically.
variant write-condition { create-only, if-match(revision), overwrite }
/// Changes since a previous polling cursor.
enum change-kind { created, modified, removed }
/// A changed resource path relative to the connection root.
record change { path: string, kind: change-kind }
/// At most 256 changes and a cursor of at most 4096 UTF-8 bytes.
record change-page { changes: list<change>, cursor: option<string> }
/// One transfer being read. The handle survives between host calls, so a
/// file of any size is read as a series of separately bounded calls.
/// Dropping it releases whatever the provider held open.
resource reader {
/// Reads onward from the last position, at most `limit` bytes, with
/// `limit` between 1 and 1,048,576. An empty result means end of file.
pull: func(limit: u32) -> result<list<u8>, error-code>;
/// The revision this transfer is reading, once the provider knows it.
/// A provider that sees the revision change mid-transfer must fail the
/// next pull with conflict rather than mix two versions.
revision: func() -> option<revision>;
}
/// One transfer being written. Dropping the handle before it finishes
/// abandons the transfer, and the provider must leave no partial file.
resource writer {
/// Appends at most 1,048,576 bytes. Oversized pushes are rejected,
/// never truncated.
push: func(data: list<u8>) -> result<_, error-code>;
/// Commits the transfer and evaluates the write condition now, against
/// the state the caller last saw. A none result means the commit needs
/// another call: keep calling until it answers some, and treat an
/// interruption in between as outcome-unknown.
finish: func() -> result<option<file-info>, error-code>;
}
/// Opens exactly one host-owned connection per component instance. The host
/// passes only this connection's configuration and in-memory credentials.
open: func(provider-id: string, config: list<tuple<string, setting>>) -> result<capabilities, error-code>;
/// Best-effort cleanup before disposal. The host may dispose immediately
/// after a trap, cancellation, permission change, or process shutdown.
close: func();
/// Reads metadata without changing the resource.
stat: func(target: locator) -> result<file-info, error-code>;
/// Enumerates 1-256 direct children per request, without recursive traversal.
%list: func(target: locator, cursor: option<string>, limit: u32) -> result<list-page, error-code>;
/// Begins reading at a byte offset. Streaming, rather than one capped read,
/// is what lets a provider serve a file larger than a single call allows.
open-read: func(target: locator, offset: u64) -> result<reader, error-code>;
/// Begins replacing one complete file if the write capability is present.
/// The size hint, when the caller knows it, lets a provider choose between
/// a single request and a multipart upload before the first byte.
open-write: func(target: locator, condition: write-condition, size-hint: option<u64>) -> result<writer, error-code>;
/// Creates one directory if the mkdir capability is present.
mkdir: func(target: locator) -> result<file-info, error-code>;
/// Renames within this connection if the rename capability is present.
rename: func(target: locator, destination: string) -> result<file-info, error-code>;
/// Removes one file or empty directory if the remove capability is present.
remove: func(target: locator) -> result<_, error-code>;
/// Polls for a bounded change page if the watch capability is present. No
/// background callback or persistent subscription is installed by the host.
poll: func(cursor: option<string>) -> result<change-page, error-code>;
/// Makes every staged write visible as one new root, if the publishable
/// capability is present, and names it, for example as a content
/// identifier. A none result means the publish needs another call.
publish: func() -> result<option<revision>, error-code>;
}
/// Memona filesystem extension ABI 0.1.0, distributed as a WebAssembly component.
/// Rust guests can target wasm32-wasip2. Selected WASI 0.2 CLI, clocks, random,
/// IO and filesystem interfaces are allowed for the standard library, with
/// empty arguments/environment, empty preopens, and discarded stdio. WASI
/// sockets and HTTP imports are not allowed; networking uses the host interface.
world filesystem {
import host;
export provider;
}