// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// The same file, written a piece at a time: nothing larger than one chunk is
/// ever held, which is what makes a file bigger than memory something this
/// library can seal.
pub fn seal_stream(
sink : &Sink,
recipients : Array[&@spec.Recipient],
source : &Source,
random~ : &@moonseed.Source,
) -> Unit raise @spec.Refused {
let file_key = @spec.draw(random, file_key_size)
let stanzas = Array(capacity=recipients.length())
for recipient in recipients {
stanzas.push(recipient.wrap(file_key~, random~))
}
// Drawn before anything reaches the sink, so a failing source leaves it
// empty rather than holding a header with no payload.
let nonce = @spec.draw(random, @stream.nonce)
let head = @header.Header::new(stanzas)
sink.write(bytes=head.encode(file_key~))
sink.write(bytes=nonce)
let sealer = @stream.Sealer::new(key=@stream.payload_key(file_key~, nonce~))
let reader = Reader::new(source)
while true {
// One byte of lookahead is what tells the last chunk from the others: the
// format marks it, so it has to be known before it is sealed.
reader.fill(want=@stream.chunk + 1)
let held = reader.available()
if held == 0 {
sink.write(bytes=sealer.chunk(plain=b""[:], last=true))
break
}
if held <= @stream.chunk {
sink.write(bytes=sealer.chunk(plain=reader.take(len=held), last=true))
break
}
sink.write(
bytes=sealer.chunk(plain=reader.take(len=@stream.chunk), last=false),
)
}
}
///|
/// Seal to a sink, as an armored text block. The text is written in the same
/// pieces the file is, so the whole of it never exists at once either.
pub fn seal_stream_armored(
sink : &Sink,
recipients : Array[&@spec.Recipient],
source : &Source,
random~ : &@moonseed.Source,
) -> Unit raise @spec.Refused {
let armor = ArmoredSink::new(sink)
seal_stream(armor, recipients, source, random~)
armor.finish()
}
///|
/// Open a file from a source, writing the plaintext to a sink as it is
/// authenticated. Nothing is written until the header has verified.
pub fn open_stream(
sink : &Sink,
identities : Array[&@spec.Identity],
source : &Source,
) -> Unit raise @spec.Refused {
let reader = Reader::new(source)
let text = read_header(reader)
let (head, _) = @header.Header::decode(text)
let file_key = file_key_of(identities, head)
reader.fill(want=@stream.nonce)
if reader.available() < @stream.nonce {
raise @spec.Refused::Header(
at=reader.available(),
want="a 16-byte payload nonce",
)
}
let nonce = reader.take(len=@stream.nonce)
let opener = @stream.Opener::new(key=@stream.payload_key(file_key~, nonce~))
let mut base = 0
while true {
reader.fill(want=@stream.chunk + @stream.tag + 1)
let held = reader.available()
if held == 0 {
raise @spec.Refused::Payload(at=@stream.nonce + base)
}
let last = held <= @stream.chunk + @stream.tag
let take = if last { held } else { @stream.chunk + @stream.tag }
let plain = opener.chunk(cipher=reader.view().clamped_view(end=take), last~) catch {
@spec.Refused::Payload(at~) =>
raise @spec.Refused::Payload(at=@stream.nonce + base + at)
error => raise error
}
sink.write(bytes=plain)
ignore(reader.take(len=take))
base = base + take
if last {
break
}
}
}
///|
/// Open an armored text block from a source.
pub fn open_stream_armored(
sink : &Sink,
identities : Array[&@spec.Identity],
source : &Source,
) -> Unit raise @spec.Refused {
open_stream(sink, identities, ArmoredSource::new(source))
}
///|
/// A source over a source, with a chunk of lookahead. One chunk is the most this
/// ever holds beyond the header, which is the most the format needs.
priv struct Reader {
source : &Source
mut held : Bytes
mut at : Int32
mut eof : Bool
}
///|
fn Reader::new(source : &Source) -> Self {
{ source, held: b"", at: 0, eof: false, }
}
///|
fn Reader::available(self : Reader) -> Int32 {
self.held.length() - self.at
}
///|
fn Reader::view(self : Reader) -> BytesView {
self.held.clamped_view(start=self.at)
}
///|
/// Pull until at least `want` bytes are buffered, or the source is spent.
fn Reader::fill(self : Reader, want~ : Int32) -> Unit raise @spec.Refused {
while self.available() < want && !self.eof {
let piece = self.source.read(limit=@stream.chunk)
if piece.length() == 0 {
self.eof = true
break
}
if self.at > 0 {
self.held = self.held.clamped_view(start=self.at).to_owned()
self.at = 0
}
self.held = self.held.add(piece)
}
}
///|
fn Reader::take(self : Reader, len~ : Int32) -> Bytes {
let piece = self.view().clamped_view(end=len).to_owned()
self.at = self.at + len
piece
}
///|
/// Read a header from a source: everything up to and including the line feed
/// after the MAC, which is where the payload starts.
///
/// The end is found by looking for the first `---` at the start of a line, which
/// no stanza body can be confused for: a body line is base64, and `-` is not in
/// that alphabet.
fn read_header(reader : Reader) -> Bytes raise @spec.Refused {
let mut want = 256
while true {
reader.fill(want~)
let view = reader.view()
if view.length() > @header.limit {
raise @spec.Refused::Limit(want=@header.limit, got=view.length())
}
let found = match view.find(b"\n--- ") {
Some(mark) => view.clamped_view(start=mark + 1).find(b"\n")
None => None
}
match found {
Some(rest) => {
let end = view.find(b"\n--- ").unwrap_or(0) + 1 + rest + 1
let text = view.clamped_view(end~).to_owned()
ignore(reader.take(len=end))
return text
}
None => ()
}
if reader.eof {
// Whatever is there is a header that never ended; asking the parser what is
// wrong with it gives a better refusal than "unexpected end".
ignore(@header.Header::decode(view))
raise @spec.Refused::Header(
at=view.length(),
want="the end of the header",
)
}
want = view.length() + @stream.chunk
}
abort("unreachable")
}
///|
/// A sink that writes the file out as armored text.
priv struct ArmoredSink {
sink : &Sink
encoder : @armor.Encoder
mut started : Bool
}
///|
fn ArmoredSink::new(sink : &Sink) -> Self {
{ sink, encoder: @armor.Encoder::new(), started: false, }
}
///|
fn ArmoredSink::finish(self : ArmoredSink) -> Unit raise @spec.Refused {
self.start()
let text = self.encoder.finish()
self.sink.write(bytes=@utf8.encode(text))
}
///|
fn ArmoredSink::start(self : ArmoredSink) -> Unit raise @spec.Refused {
if !self.started {
self.started = true
self.sink.write(bytes=@utf8.encode(@armor.head()))
}
}
///|
impl Sink for ArmoredSink with fn write(self, bytes~) {
self.start()
let text = self.encoder.write(bytes)
if text.length() > 0 {
self.sink.write(bytes=@utf8.encode(text))
}
}
///|
/// A source that decodes an armored text block and hands out the file.
priv struct ArmoredSource {
source : &Source
decoder : @armor.Decoder
mut pending : Bytes
mut done : Bool
}
///|
fn ArmoredSource::new(source : &Source) -> Self {
{ source, decoder: @armor.Decoder::new(), pending: b"", done: false, }
}
///|
impl Source for ArmoredSource with fn read(self, limit~) {
while true {
if self.pending.length() > 0 {
let take = if self.pending.length() < limit {
self.pending.length()
} else {
limit
}
let piece = self.pending.clamped_view(end=take).to_owned()
self.pending = self.pending.clamped_view(start=take).to_owned()
return piece
}
if self.done {
return b""
}
let text = self.source.read(limit=@stream.chunk)
if text.length() > 0 {
self.decoder.push(@utf8.decode_lossy(text))
self.pending = self.decoder.take()
continue
}
self.pending = self.decoder.finish()
self.done = true
}
b""
}