`Io.Reader` buffer and `peek` paradox

I am probably overthinking something, but I’ve hit a bit of a quandary while trying to support peek for a base64 Io.Reader implementation. The issue is that peek expects stream to fill up to the full buffer for the Io.Reader. But the base64 decoding ratio is 4 to 3, which can result in needing to read more than one byte from the input reader to fill the next byte in the buffer. So on a peek that fills up the buffer with a partial chunk, the full chunk needs to stay in a buffer somewhere for the next time stream reads from the input reader. Further, there is no guarantee that the upstream reader’s buffer is large enough to hold a chunk, and there is no way to use the interface’s buffer since the entire buffer can be used by the interface.

The solution I have come up with is to wrap the input reader in an indirect reader that has a chunk length buffer:

pub const Reader = struct {
    pub const min_buffer_len = 3;
    pub const chunk_len = 4;

    in: *Io.Reader,
    decoder: base64.Base64Decoder,
    interface: Io.Reader,
    err: ?base64.Error,

    indirect: ?Indirect,
    decoded_offset: u2,

    const Indirect = ReaderIndirect(chunk_len);

    pub fn init(in: *Io.Reader, decoder: base64.Base64Decoder, buffer: []u8) Reader {
        assert(buffer.len >= min_buffer_len);

        return .{
            .in = in,
            .decoder = decoder,
            .interface = .{
                .vtable = &.{
                    .stream = stream,
                    .readVec = readVec,
                },
                .buffer = buffer,
                .end = 0,
                .seek = 0,
            },
            .err = null,
            .indirect = null,
            .decoded_offset = 0,
        };
    }

    fn stream(r: *Io.Reader, _: *Io.Writer, _: Io.Limit) Io.Reader.StreamError!usize {
        try readChunk(r);
        return 0;
    }

    fn readVec(r: *Io.Reader, _: [][]u8) Io.Reader.Error!usize {
        try readChunk(r);
        return 0;
    }

    fn readChunk(r: *Io.Reader) Io.Reader.Error!void {
        const self: *Reader = @fieldParentPtr("interface", r);
        var in = if (self.in.buffer.len >= chunk_len) self.in else in: {
            if (self.indirect == null) {
                Indirect.init(&self.indirect, self.in);
            }
            break :in &self.indirect.?.interface;
        };

        // peek a chunk, or at least one byte worth of decoded data (2 bytes).
        const encoded = enc: {
            const buf = try in.peekGreedy(2);
            break :enc buf[0..@min(buf.len, chunk_len)];
        };
        const eos_seen = (encoded.len < chunk_len);
        assert(encoded.len <= chunk_len);

        const decoded_len = self.decoder.calcSizeForSlice(encoded) catch |err| {
            if (eos_seen) return error.EndOfStream;
            self.err = err;
            return error.ReadFailed;
        };
        assert(decoded_len <= Writer.chunk_len);

        if (eos_seen and decoded_len == 0) return error.EndOfStream;
        try r.rebase(1);

        const decoded = dec: {
            var buf: [chunk_len]u8 = undefined;
            const chunk = buf[0..decoded_len];
            self.decoder.decode(chunk, encoded) catch |err| {
                if (eos_seen) return error.EndOfStream;
                self.err = err;
                return error.ReadFailed;
            };
            break :dec chunk;
        };

        var offset: usize = @intCast(self.decoded_offset);
        assert(offset < decoded_len);

        const remaining = decoded_len - offset;
        const n = @min(r.buffer.len - r.end, remaining);
        @memcpy(r.buffer[r.end..][0..n], decoded[offset..][0..n]);
        r.end += n;
        offset += n;

        const chunk_delivered = offset == decoded_len;
        if (chunk_delivered) {
            in.toss(encoded.len);
            self.decoded_offset = 0;
        } else {
            self.decoded_offset = @intCast(offset);
        }
    }
};

But I’m really unhappy with the ergonomics of ReaderIndirect, which can’t be safely copied and so it can’t initialize it’s (self-referential) interface field until it can access *Self:

fn ReaderIndirect(buffer_len: comptime_int) type {
    return struct {
        const Self = @This();

        in: *Io.Reader,
        interface: Io.Reader,
        buffer: [buffer_len]u8,

        pub fn init(self: *?Self, in: *Io.Reader) void {
            self.* = .{
                .in = in,
                .interface = undefined,
                .buffer = undefined,
            };
            self.*.?.interface = .{
                .vtable = &.{
                    .stream = Self.stream,
                    .readVec = Self.readVec,
                },
                .buffer = &self.*.?.buffer,
                .seek = 0,
                .end = 0,
            };
        }

        // the rest is copied from testing.ReaderIndirect

        fn readVec(r: *Io.Reader, _: [][]u8) Io.Reader.Error!usize {
            try streamInner(r);
            return 0;
        }

        fn stream(r: *Io.Reader, _: *Io.Writer, _: Io.Limit) Io.Reader.StreamError!usize {
            try streamInner(r);
            return 0;
        }

        fn streamInner(r: *Io.Reader) Io.Reader.Error!void {
            const r_indirect: *@This() = @alignCast(@fieldParentPtr("interface", r));

            // If there's no room remaining in the buffer at all, make room.
            if (r.buffer.len == r.end) {
                try r.rebase(r.buffer.len);
            }

            var writer: Io.Writer = .{
                .buffer = r.buffer,
                .end = r.end,
                .vtable = &.{
                    .drain = Io.Writer.unreachableDrain,
                    .rebase = Io.Writer.unreachableRebase,
                },
            };
            defer r.end = writer.end;

            r_indirect.in.streamExact(&writer, r.buffer.len - r.end) catch |err| switch (err) {
                // Only forward EndOfStream if no new bytes were written to the buffer
                error.EndOfStream => |e| if (r.end == writer.end) {
                    return e;
                },
                error.WriteFailed => unreachable,
                else => |e| return e,
            };
        }
    };
}

It seems like any reader implementation that needs to read past it’s buffers
length of bytes from it’s input reader has this problem, are there other
simpler solutions to this out there?

I didn’t understand anything you described, but it appears you’re reading data from a reader, processing, and then serving the processed data through the reader interface.

If this is the crux of your problem, you can simply require that the input reader have a large enough buffer to hold a chunk. In the reader interface docs, it is stated the implementers are expected to place minimum bounds on the sizes of buffers.

It already assert a minimum size of the buffer parameter to init (it technically can be relaxed to > 0), but to properly support peek there is a minimum buffer size requirement on the in parameter’s buffer. This means that the Reader won’t be compatible with all input Io.Readers, and AFAIK there’s nothing in the docs about how to deal with input reader’s buffer requirements.

Here’s a pseudo code example to hopefully explains the issue better:

// "QUFBQUEK" decodes to "AAAAA"
var stream: Io.Reader = .fixed("QUFBQUEK");

// setup an `in` reader with a 2 byte buffer that reads from `stream`
var short_buf: [2]byte = undefined;
var in: std.testing.ReaderIndirect = .init(&stream, &short_buf);

// setup the base64 reader with a 4 byte buffer that reads from `in`
var chunk_buf: [4]byte = undefined;
var b64: Base64Reader = .init(&in.interface, &chunk_buf);
var r: *Io.Reader = &b64.interface;

// peek 2 bytes from `b64`, it's buffer is now 'AAA' after reading the first chunk'
assert(std.mem.eql(u8, "AA", try r.peek(2)));
assert(std.mem.eql(u8, "AAA", r.buffered()));

// since 4 bytes were consumed from `in`, the next bytes read from `in` will be
// "QUEK", and it's buffer can only be "", "Q", or "QU"
assert(std.mem.eql(u8, "", in.interface.buffered()) or 
       std.mem.eql(u8, "Q", in.interface.buffered()) or 
       std.mem.eql(u8, "QU", in.interface.buffered()));

// taking 4 bytes requires b64 to read both chunks from `in`, but it can only
// hold part of the second chunk.
assert(std.mem.eql(u8, "AAAA", try r.take(4))); // AAA is from the first chunk, A is from the second
assert(std.mem.eql(u8, "", r.buffered()));      // but the rest of the 2nd chunk wouldn't fit in r's buffer (at the end of `stream`)

// since r had to read both chunks, and the second chunk was 4 bytes, it
// cannot all still be held `in`'s 2 byte buffer, the "QU" couldn't be `peek`d.
assert(std.mem.eql(u8, "", in.interface.buffered() or
       std.mem.eql(u8, "E", in.interface.buffered()) or
       std.mem.eql(u8, "EK", in.interface.buffered()));

// so the next read to `b64` can't recover the remaining parts of the second chunk,
// which means there's no way to read the last 'A'.
assert(std.mem.eql(u8, "A", try r.take(1)); // kaboom!

Changing the size of chunk_buf to something larger doesn’t fix the problem, at some point a peek’ing consumer will fill up the buffer with a partial chunk. The only solution I see is to enforce a minimum buffer size on the input reader, or layer in the necessary buffer myself.

1 Like

Yes, this would be the typical way of doing it, and what you see in the decompression APIs of the standard library. I would strongly opt for the former here to avoid unnecessary copies unless you really want to accept anything. It is understandable to want to keep your input as generic as possible, but there are times where you need to impose restrictions beyond just accepting any 'ol std.Io.Reader, and this would be an example of one of those times.

I would do the following:

  • Document the minimum buffer size for the input reader’s buffer
  • Assert/error if it is too small
  • Create a public constant that consumers can use such (i.e. min_buffer_size) when initializing their buffer. This makes it apparent that the size is not arbitrary, as well as makes it easy for you to change in the future.

In practice, this is not an imposition to consumers if that is your concern.

6 Likes

Here’s roughly how I’d go about it, sticking with your one-chunk-at-a-time approach (would probably be better to write as many chunks as possible each stream instead, see my edit below):

pub const Reader = struct {
    pub const min_buffer_len = decoded_chunk_len;
    pub const decoded_chunk_len = 3;
    pub const encoded_chunk_len = 4;

    in: *Io.Reader,
    decoder: base64.Base64Decoder,
    interface: Io.Reader,
    err: ?base64.Error,

    pub fn init(in: *Io.Reader, decoder: base64.Base64Decoder, buffer: []u8) Reader {
        assert(buffer.len >= min_buffer_len);

        return .{
            .in = in,
            .decoder = decoder,
            .interface = .{
                .vtable = &.{
                    .stream = stream,
                    .readVec = readVec,
                },
                .buffer = buffer,
                .end = 0,
                .seek = 0,
            },
            .err = null,
        };
    }

    fn stream(r: *Io.Reader, _: *Io.Writer, _: Io.Limit) Io.Reader.StreamError!usize {
        try readChunk(r);
        return 0;
    }

    fn readVec(r: *Io.Reader, _: [][]u8) Io.Reader.Error!usize {
        try readChunk(r);
        return 0;
    }

    fn readChunk(r: *Io.Reader) Io.Reader.Error!void {
        const self: *Reader = @fieldParentPtr("interface", r);

        var buf: [encoded_chunk_len]u8 = undefined;
        const encoded_len = try self.in.readSliceShort(&buf);
        const eos_seen = encoded_len < encoded_chunk_len;
        const encoded = buf[0..encoded_len];

        const decoded_len = self.decoder.calcSizeForSlice(encoded) catch |err| {
            self.err = err;
            return error.ReadFailed;
        };

        if (eos_seen and decoded_len == 0) return error.EndOfStream;

        if (r.buffer.len - r.end < decoded_chunk_len) {
            // We know we're using defaultRebase which can't fail
            r.vtable.rebase(r, decoded_chunk_len) catch unreachable;
        }

        const decoded = r.buffer[r.end..][0..decoded_len];
        self.decoder.decode(decoded, encoded) catch |err| {
            self.err = err;
            return error.ReadFailed;
        };
        r.end += decoded_len;
    }
};

test Reader {
    var in: Io.Reader = .fixed("QUFBQUFB");

    var chunk_buf: [Reader.min_buffer_len]u8 = undefined;
    var b64: Reader = .init(&in, base64.standard.Decoder, &chunk_buf);
    var r: *Io.Reader = &b64.interface;

    const out = try r.allocRemaining(std.testing.allocator, .unlimited);
    defer std.testing.allocator.free(out);

    try std.testing.expectEqualStrings("AAAAAA", out);
}

There’s no need to assert the length of the buffer of in reader since we only rely on readSliceShort, which doesn’t require the in reader to hold the whole chunk in the buffer at once.

(note: there are Reader implementation variants that will blow up if used as in, but they are special cases that have many sharp and unresolved edge cases)

EDIT: Here’s a function that reads multiple chunks at-a-time if the buffer size allows it:

    fn readChunks(r: *Io.Reader) Io.Reader.Error!void {
        const self: *Reader = @fieldParentPtr("interface", r);

        // If necessary, make room now to ensure we can at least buffer one chunk
        if (r.buffer.len - r.end < decoded_chunk_len) {
            // We know we're using defaultRebase which can't fail
            r.vtable.rebase(r, decoded_chunk_len) catch unreachable;
        }

        const starting_end = r.end;
        while (r.buffer.len - r.end >= decoded_chunk_len) {
            var buf: [encoded_chunk_len]u8 = undefined;
            const encoded_len = try self.in.readSliceShort(&buf);
            if (encoded_len == 0) break;
            const encoded = buf[0..encoded_len];

            const decoded_len = self.decoder.calcSizeForSlice(encoded) catch |err| {
                self.err = err;
                return error.ReadFailed;
            };

            const decoded = r.buffer[r.end..][0..decoded_len];
            self.decoder.decode(decoded, encoded) catch |err| {
                self.err = err;
                return error.ReadFailed;
            };
            r.end += decoded_len;
        }
        // No chunks available, we're at EOS
        if (r.end == starting_end) return error.EndOfStream;
    }

@squeek502 this test reaches unreachable code inside defaultRebase in your version:

test Reader {
    var in: Io.Reader = .fixed("QUFBQkJC");

    var chunk_buf: [Reader.min_buffer_len]u8 = undefined;
    var b64: Reader = .init(&in, base64.standard.Decoder, &chunk_buf);
    var r: *Io.Reader = &b64.interface;

    try std.testing.expectEqualStrings("AA", try r.take(2));
    try std.testing.expectEqualStrings("AB", try r.take(2)); // kaboom
    try std.testing.expectEqualStrings("BB", try r.take(2));
}

Because r.buffered() is “AA” and r.buffer.len is 3, it can’t fit 3 more bytes.

1 Like

@ForeverZer0 thanks for the advice, I think I’m settling on having an init that adds the indirect buffer and a initDirect to bypass input buffering:

pub const Reader = struct {
    pub const min_buffer_len = 7;
    pub const decoded_chunk_len = 3;
    pub const encoded_chunk_len = 4;

    in: *Io.Reader,
    decoder: base64.Base64Decoder,
    interface: Io.Reader,
    err: ?base64.Error,

    indirect: ?std.testing.ReaderIndirect,
    decoded_offset: u2,

    /// Construct a buffered Reader, minimum length of buffer is 7.
    pub fn init(in: *Io.Reader, decoder: base64.Base64Decoder, buffer: []u8) Reader {
        assert(buffer.len >= encoded_chunk_len + decoded_chunk_len);

        return .{
            .in = in,
            .decoder = decoder,
            .interface = .{
                .vtable = &.{
                    .stream = stream,
                    .readVec = readVec,
                },
                .buffer = buffer[encoded_chunk_len..],
                .end = 0,
                .seek = 0,
            },
            .err = null,
            .indirect = .init(in, buffer[0..encoded_chunk_len]),
            .decoded_offset = 0,
        };
    }

    /// Construct a direct Reader, in must have a buffer length of at least 4,
    /// minimum buffer length is 3.
    pub fn initDirect(in: *Io.Reader, decoder: base64.Base64Decoder, buffer: []u8) Reader {
        assert(buffer.len >= decoded_chunk_len);
        assert(in.buffer.len >= encoded_chunk_len);

        return .{
            .in = in,
            .decoder = decoder,
            .interface = .{
                .vtable = &.{
                    .stream = stream,
                    .readVec = readVec,
                },
                .buffer = buffer,
                .end = 0,
                .seek = 0,
            },
            .err = null,
            .indirect = null,
            .decoded_offset = 0,
        };
    }

    fn stream(r: *Io.Reader, _: *Io.Writer, _: Io.Limit) Io.Reader.StreamError!usize {
        try readChunk(r);
        return 0;
    }

    fn readVec(r: *Io.Reader, _: [][]u8) Io.Reader.Error!usize {
        try readChunk(r);
        return 0;
    }

    fn readChunk(r: *Io.Reader) Io.Reader.Error!void {
        const self: *Reader = @fieldParentPtr("interface", r);
        var in: *Io.Reader = if (self.indirect) |*indirect| &indirect.*.interface else self.in;

        // peek a chunk, or at least one byte worth of decoded data (2 bytes).
        const encoded = enc: {
            const buf = try in.peekGreedy(2);
            break :enc buf[0..@min(buf.len, encoded_chunk_len)];
        };
        const eos_seen = (encoded.len < encoded_chunk_len);
        assert(encoded.len <= encoded_chunk_len);

        const decoded_len = self.decoder.calcSizeForSlice(encoded) catch |err| {
            if (eos_seen) return error.EndOfStream;
            self.err = err;
            return error.ReadFailed;
        };
        assert(decoded_len <= Writer.chunk_len);

        if (eos_seen and decoded_len == 0) return error.EndOfStream;
        try r.rebase(1);

        const decoded = dec: {
            var buf: [decoded_chunk_len]u8 = undefined;
            const chunk = buf[0..decoded_len];
            self.decoder.decode(chunk, encoded) catch |err| {
                if (eos_seen) return error.EndOfStream;
                self.err = err;
                return error.ReadFailed;
            };
            break :dec chunk;
        };

        var offset: usize = @intCast(self.decoded_offset);
        assert(offset < decoded_len);

        const remaining = decoded_len - offset;
        const n = @min(r.buffer.len - r.end, remaining);
        @memcpy(r.buffer[r.end..][0..n], decoded[offset..][0..n]);
        r.end += n;
        offset += n;

        const chunk_delivered = offset == decoded_len;
        if (chunk_delivered) {
            in.toss(encoded.len);
            self.decoded_offset = 0;
        } else {
            self.decoded_offset = @intCast(offset);
        }
    }

Good point, sorry about that. In general, the advice for “indirect” readers like this (ones that always return 0 from stream/readVec) should probably be (updated from what I’ve said before):

  • At the beginning, if necessary (i.e. r.end == r.buffer.len), rebase to free up space in the buffer
  • Refill the buffer as much as possible, up to exactly r.buffer.len if enough data is available to do so
    • That is, you always need to be able to write to the buffer in 1-byte granularity. Waiting for enough space to write a chunk of len > 1 won’t cut it due to the possibility of fill(r.buffer.len) with arbitrary r.seek/r.end values
  • Store any state necessary to continue from where you left off
  • Return 0

To that end, here’s one possible implementation of that:

const std = @import("std");
const Io = std.Io;
const base64 = std.base64;
const assert = std.debug.assert;

pub const Reader = struct {
    /// Technically the minimum required size, but would be very inefficient to use this size
    pub const min_buffer_len = 1;
    pub const decoded_chunk_len = 3;
    pub const encoded_chunk_len = 4;

    in: *Io.Reader,
    decoder: base64.Base64Decoder,
    interface: Io.Reader,
    err: ?base64.Error,
    chunk_buf: [decoded_chunk_len]u8,
    chunk_remaining: DecodedChunkLen,
    chunk_start: DecodedChunkLen,

    const DecodedChunkLen = std.math.IntFittingRange(0, decoded_chunk_len);

    pub fn init(in: *Io.Reader, decoder: base64.Base64Decoder, buffer: []u8) Reader {
        assert(buffer.len >= min_buffer_len);

        return .{
            .in = in,
            .decoder = decoder,
            .interface = .{
                .vtable = &.{
                    .stream = stream,
                    .readVec = readVec,
                },
                .buffer = buffer,
                .end = 0,
                .seek = 0,
            },
            .err = null,
            .chunk_buf = undefined,
            .chunk_remaining = 0,
            .chunk_start = 0,
        };
    }

    fn stream(r: *Io.Reader, _: *Io.Writer, _: Io.Limit) Io.Reader.StreamError!usize {
        try readChunks(r);
        return 0;
    }

    fn readVec(r: *Io.Reader, _: [][]u8) Io.Reader.Error!usize {
        try readChunks(r);
        return 0;
    }

    fn readChunks(r: *Io.Reader) Io.Reader.Error!void {
        const self: *Reader = @fieldParentPtr("interface", r);

        // If there's no room in the buffer, make room
        if (r.end == r.buffer.len) {
            // We know we're using defaultRebase which can't fail
            r.vtable.rebase(r, 1) catch unreachable;
        }

        const starting_end = r.end;

        if (self.chunk_remaining > 0) {
            r.end += self.flushInternalChunk();
            if (self.chunk_remaining > 0) return;
        }

        while (true) {
            var buf: [encoded_chunk_len]u8 = undefined;
            const encoded_len = try self.in.readSliceShort(&buf);
            if (encoded_len == 0) break;
            const encoded = buf[0..encoded_len];

            const decoded_len = self.decoder.calcSizeForSlice(encoded) catch |err| {
                self.err = err;
                return error.ReadFailed;
            };

            // If the whole chunk will fit in the buffer, decode to the buffer directly.
            if (r.buffer.len - r.end >= decoded_len) {
                const decoded = r.buffer[r.end..][0..decoded_len];
                self.decoder.decode(decoded, encoded) catch |err| {
                    self.err = err;
                    return error.ReadFailed;
                };
                r.end += decoded_len;
            }
            // Otherwise, decode the chunk into an internal buffer and then copy as much of it
            // into the real buffer as possible before returning.
            else {
                self.decoder.decode(&self.chunk_buf, encoded) catch |err| {
                    self.err = err;
                    return error.ReadFailed;
                };
                self.chunk_remaining = @intCast(decoded_len);
                self.chunk_start = 0;
                r.end += self.flushInternalChunk();
                return;
            }
        }
        // No chunks available, we're at EOS
        if (r.end == starting_end) return error.EndOfStream;
    }

    /// Returns the number of bytes copied from `self.chunk_buf` into `self.interface.buffer`
    fn flushInternalChunk(self: *Reader) DecodedChunkLen {
        const r = &self.interface;
        const available_len = @min(self.chunk_remaining, r.buffer.len - r.end);
        @memcpy(r.buffer[r.end..][0..available_len], self.chunk_buf[self.chunk_start..][0..available_len]);
        self.chunk_remaining -= available_len;
        self.chunk_start += available_len;
        return available_len;
    }
};

test Reader {
    var in: Io.Reader = .fixed("QUFBQUFB");

    var chunk_buf: [Reader.min_buffer_len]u8 = undefined;
    var b64: Reader = .init(&in, base64.standard.Decoder, &chunk_buf);
    var r: *Io.Reader = &b64.interface;

    const out = try r.allocRemaining(std.testing.allocator, .unlimited);
    defer std.testing.allocator.free(out);

    try std.testing.expectEqualStrings("AAAAAA", out);
}

test "take across chunk boundaries" {
    var in: Io.Reader = .fixed("QUFBQkJC");

    var chunk_buf: [4]u8 = undefined;
    var b64: Reader = .init(&in, base64.standard.Decoder, &chunk_buf);
    var r: *Io.Reader = &b64.interface;

    try std.testing.expectEqualStrings("AAAB", try r.take(4));
    try std.testing.expectEqualStrings("BB", try r.take(2));
}

test "min buffer size" {
    var in: Io.Reader = .fixed("QUFBQkJC");

    var chunk_buf: [Reader.min_buffer_len]u8 = undefined;
    var b64: Reader = .init(&in, base64.standard.Decoder, &chunk_buf);
    var r: *Io.Reader = &b64.interface;

    try std.testing.expectEqual('A', try r.takeByte());
    try std.testing.expectEqual('A', try r.takeByte());
    try std.testing.expectEqual('A', try r.takeByte());
    try std.testing.expectEqual('B', try r.takeByte());
    try std.testing.expectEqual('B', try r.takeByte());
    try std.testing.expectEqual('B', try r.takeByte());
}
1 Like

I think this version has issues too, it looks like it can incorrectly return StreamTooLong on a discardDelimiterLimit that should be found. (Sorry for the large test input, I don’t (yet) have a good way of reducing them.)

I’m not exactly sure where the bug is, but the base64 chunking looks correct so i suspect it’s in buffer management, because it works if the buffer is size 3. And your advice for indirect reader implementations seems right to me.

1 Like

Good catch, dumb mistake by me; it was always copying from the beginning of the internal chunk buf. The @memcpy in flushInternalChunk should be changed like so:

- @memcpy(r.buffer[r.end..][0..available_len], self.chunk_buf[0..available_len]);
+ @memcpy(r.buffer[r.end..][0..available_len], self.chunk_buf[self.chunk_start..][0..available_len]);

(edited my post above to include that fix)

1 Like