Working version with some test

master
nixo 2020-10-23 11:13:35 +02:00
commit 0bbd8b261b
7 changed files with 326 additions and 0 deletions

4
Manifest.toml Normal file
View File

@ -0,0 +1,4 @@
# This file is machine-generated - editing it directly is not advised
[[Sockets]]
uuid = "6462fe0b-24de-5631-8697-dd941f90decc"

8
Project.toml Normal file
View File

@ -0,0 +1,8 @@
name = "Gemenon"
uuid = "276b1fd0-8d54-4f56-89ad-750bff7bc49a"
authors = ["nixo <nicolo@nixo.xyz>"]
version = "0.1.0"
[deps]
Sockets = "6462fe0b-24de-5631-8697-dd941f90decc"
OpenSSL = "13378672-9967-48ef-8ae3-b2866786e7a0"

22
src/Gemenon.jl Normal file
View File

@ -0,0 +1,22 @@
module Gemenon
using OpenSSL
using Sockets
export Connection, Request, Status, Response
include("types.jl")
include("server.jl")
import Base.write
write(c::Connection, data::Vector{UInt8}) = OpenSSL.write(c.client, data)
function write(conn::Connection, s::Status)
write(conn.client,
string(s.major, s.minor, ' ', s.meta, '\r', '\n'))
end
function write(conn::Connection, r::Response)
write(conn, r.status)
write(conn.client, r.body)
end
end # module

96
src/server.jl Normal file
View File

@ -0,0 +1,96 @@
function listen(f,
context::SSLContext,
host::Union{IPAddr, String} = Sockets.localhost,
port::Integer = 1965
;
connection_count::Ref{Int} = Ref(0),
readtimeout::Int = 0,
verbose::Bool = false)
server = Sockets.listen(Sockets.InetAddr(host, port))
verbose && @info "Listening on: $host:$port"
return listenloop(f, server, context, connection_count, readtimeout, verbose)
end
""""
Main server loop.
Accepts new tcp connections and spawns async tasks to handle them."
"""
function listenloop(f, server, context, connection_count, readtimeout, verbose)
count = 1
while isopen(server)
try
# @info "Ready to accept new connection"
io = accept(server)
if io === nothing
verbose && @warn "unable to accept new connection"
continue
end
connection_count[] += 1
@async try
handle_connection(f, server, context, io, verbose)
catch e
if e isa Base.IOError && e.code == -54
verbose && @warn "connection reset by peer (ECONNRESET)"
else
@error exception=(e, stacktrace(catch_backtrace()))
end
finally
connection_count[] -= 1
# handle_connection is in charge of closing the underlying io
end
catch e
close(server)
if e isa InterruptException
@warn "Interrupted: listen($server)"
break
else
rethrow(e)
end
end
count += 1
end
return
end
function handle_connection(f, server, context, io, verbose)
client = SSLClient(context, io)
while isopen(server) && isopen(io)
try
content = UInt8[]
client.io_on_read = (x) -> append!(content, x)
(ip, client_port) = Sockets.getpeername(io)
while true
if isreadable(io) && length(client.write_buf) == 0
# verbose && println("do_read")
if OpenSSL.do_sock_read(client) == -1
break
end
end
if iswritable(io) && length(client.write_buf) > 0
# verbose && println("do_write")
if OpenSSL.do_sock_write(client) == -1
break
end
end
# verbose && println("end loop")
if OpenSSL.ssl_init_finished(client)
# verbose && println("init_finished")
# TODO: add a timeout!
while isopen(server) && isopen(io) &&
(length(content) == 0 || bytesavailable(client.sock) > 0)
# println("HERE")
OpenSSL.do_sock_read(client)
end
f(Connection(server, client), Request(String(content)))
break
end
end
catch e
rethrow(e)
finally
close(client)
end
end
end

0
src/statuses.jl Normal file
View File

136
src/types.jl Normal file
View File

@ -0,0 +1,136 @@
struct Status
major::Char
minor::Char
meta::String
# FIXME: error codes
function Status(ma::Char, mi::Char, meta::String)
'1' <= ma <= '6' || throw("Invalid")
'0' <= mi <= '9' || throw("Invalid")
if length(meta) > 1024
throw("Invalid")
end
new(ma, mi, meta)
end
end
Status(code::String, meta::String) = Status(code[1], code[2], meta)
Status(code::String) = Status(code[1], code[2], "")
Status(code::Int, meta::String) = Status(string(code), meta)
Status(code::Int) = Status(string(code))
struct Response
status::Status
body::String
end
Response(s::Status, b::Vector{String}) = Response(s, join(b, "\n"))
struct Request
protocol::Union{String,Nothing}
host::Union{String,Nothing}
port::Union{Int32,Nothing}
path::Union{Array{String},Nothing}
query::Union{String,Nothing}
valid::Bool
raw::Union{String,Vector{UInt8}}
"Assemble a Request object based on raw string, according to gemini specifications
(v0.14.2, July 2nd 2020)
[protocol://]host[:port][/path][?query]
Protocol: optional, default gemini://
Port: optional, default 1965
Host: required
Path: optional
Query: optional
spaces encoded with: %20"
end
function Request(data::String)
reg = r"""
((?P<protocol>[^:/]+)://)? # optional protocol
(?P<host>[^:/]+) # required host
(:(?P<port>[0-9]{1,5}))? # optional port (will match impossible 65000+ ports)
(/(?P<path>[^\?]+))? # optional path
(\?(?P<query>[^\?]+))? # optional query
"""x
s = endswith(data, "\r\n") ? data[1:end-2] : data
m = match(reg, s)
if isnothing(m)
Request(nothing, nothing, nothing, nothing, nothing, false, data)
else
Request(something(m["protocol"], "gemini"),
m["host"], parse(Int, something(m["port"], "1965")),
# normpath prevents "./path" being different from "path", and
# resolves also "../". The only problem is that "./" is
# transformed to ".", so we filter it. the raw rapresentation
# is still there, if needed
isnothing(m["path"]) ? nothing : filter!(!=("."), split(normpath(m["path"]), "/")),
isnothing(m["query"]) ? nothing : unescape(m["query"]),
true, data)
end
end
"unescape(percent_encoded_uri)::String
Replace %xx escaped chars with their single-character equivalent.
Should never throw errors"
function unescape(str)::String
occursin("%", str) || return str
len = length(str)
# Maximum length is the input string length.
# Should be len - 3 (since we already know there's at least one percent sign)
# but what if the percent is at the end? We can save only one char
# as "string%" -> saves 1, "string%2" saves 2. But %-- occupies all three
unescaped = Vector{UInt8}(undef, len)
pos = 1
chars = 0
while pos <= len
let c = str[pos]
chars += 1
if c == '%' && pos+2 <= len &&
# Keep only alphanumeric chars
'0' <= str[pos+1] <= 'f' &&
'0' <= str[pos+2] <= 'f'
unescaped[chars] = parse(UInt8, str[pos+1:pos+2], base = 16)
pos += 3
else
unescaped[chars] = c
pos += 1
end
end
end
return String(unescaped[1:chars])
end
import Base.show
function show(io::IO, r::Request)
# Useful for debugging
r.valid || begin
println(r.raw)
print(io, "[!invalid] ", String(r.raw))
return
end
printstyled(io, r.protocol, color = :red)
print(io, "://")
printstyled(io, r.host, color = :black, bold = :true)
print(io, ":")
printstyled(io, r.port, color = :blue)
if !isnothing(r.path)
print(io, "/")
printstyled(io, join(r.path, "/"), color = :green)
end
if !isnothing(r.query)
print(io, "?")
printstyled(io, r.query, color = :yellow)
end
end
struct Connection
server::Sockets.TCPServer
client::SSLClient
end

60
test/URIs.jl Normal file
View File

@ -0,0 +1,60 @@
using Test
unescape_expected = [
"String without percent" => "String without percent",
"String%20with%20spaces" => "String with spaces",
"String%20with some spaces" => "String with some spaces",
"String%20with invalid end%" => "String with invalid end%",
"String%20with invalid end%a" => "String with invalid end%a",
"String%20with valid end%aa" => "String with valid end\xaa",
"String%20with invalid%-- data" => "String with invalid%-- data",
"Lech_Kaczy%C5%84ski" => "Lech_Kaczyński"
]
@testset "Unescape Query String" begin
for t in unescape_expected
@test unescape(t[1]) == t[2]
end
end
function test_request(input::String, params)
url = Request(input)
@test url.protocol == params[1]
@test url.host == params[2]
@test url.port == params[3]
@test url.path == params[4]
@test url.query == params[5]
end
@testset "Parse urls" begin
@testset "Complete url" begin
test_request("https://hostname:999/a?b",
("https", "hostname", 999, ["a"], "b"))
end
@testset "Defaults" begin
@testset "Default protocol" begin
test_request("hostname:999/a?b",
("gemini", "hostname", 999, ["a"], "b"))
end
@testset "Default port" begin
test_request("gemini://hostname/a?b",
("gemini", "hostname", 1965, ["a"], "b"))
end
end
@testset "Paths" begin
test_request("hostname/a/b/c?b",
("gemini", "hostname", 1965, ["a", "b", "c"], "b"))
test_request("hostname/a/./c?b",
("gemini", "hostname", 1965, ["a", "c"], "b"))
# Not sure if this is expected or if we should remove empty...
test_request("hostname/a/b/c/..?b",
("gemini", "hostname", 1965, ["a", "b", ""], "b"))
end
@testset "Query" begin
test_request("hostname/a/b/c",
("gemini", "hostname", 1965, ["a", "b", "c"], nothing))
test_request("hostname/a/b/c?example",
("gemini", "hostname", 1965, ["a", "b", "c"], "example"))
test_request("hostname/a/b/c?percent%20encoding",
("gemini", "hostname", 1965, ["a", "b", "c"], "percent encoding"))
end
end