diff options
| author | Richard Qin <richard.qin.x@gmail.com> | 2026-10-30 02:24:12 +0800 |
|---|---|---|
| committer | Richard Qin <richard.qin.x@gmail.com> | 2026-10-30 02:24:12 +0800 |
| commit | 464313d02b76385d3a2e8809a4595afabca88dae (patch) | |
| tree | 6cbf81416b856ff15fdc4239de5e3624ef10b2cc /ricochet/src/net | |
| parent | cf58b3b4dfa0dbbd0ca492484ca241b320c4ba9e (diff) | |
| parent | bdefdb301db843e87aee8c6c35808d295bb66ee5 (diff) | |
| download | rcc-project-464313d02b76385d3a2e8809a4595afabca88dae.tar.gz rcc-project-464313d02b76385d3a2e8809a4595afabca88dae.tar.xz | |
Diffstat (limited to 'ricochet/src/net')
| -rw-r--r-- | ricochet/src/net/config.cc | 64 | ||||
| -rw-r--r-- | ricochet/src/net/cookie_jar.cc | 124 | ||||
| -rw-r--r-- | ricochet/src/net/http_client.cc | 212 | ||||
| -rw-r--r-- | ricochet/src/net/http_utils.cc | 93 | ||||
| -rw-r--r-- | ricochet/src/net/ssl_socket.cc | 118 | ||||
| -rw-r--r-- | ricochet/src/net/sys/address.cc | 207 | ||||
| -rw-r--r-- | ricochet/src/net/sys/file_descriptor.cc | 213 | ||||
| -rw-r--r-- | ricochet/src/net/sys/socket.cc | 286 | ||||
| -rw-r--r-- | ricochet/src/net/url_parser.cc | 87 |
9 files changed, 1404 insertions, 0 deletions
diff --git a/ricochet/src/net/config.cc b/ricochet/src/net/config.cc new file mode 100644 index 0000000..6f257df --- /dev/null +++ b/ricochet/src/net/config.cc @@ -0,0 +1,64 @@ +/* + * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation. + * Copyright (C) 2026 Richard Qin + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +#include "ricochet/net/config.hh" + +#include <cstdlib> +#include <fstream> +#include <sys/stat.h> + +namespace ricochet::net { + +std::string get_config_path( const std::string& filename ) +{ + const char* home = std::getenv( "HOME" ); // NOLINT(concurrency-mt-unsafe) + const std::string dir = ( home != nullptr ) ? std::string( home ) + "/.ricochet" : ".ricochet"; + static_cast<void>( mkdir( dir.c_str(), 0755 ) ); + return dir + "/" + filename; +} + +BrowserConfig& get_browser_config() +{ + static BrowserConfig config { .user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + .homepage = "https://wikipedia.org" }; + static bool loaded = false; + if ( !loaded ) { + std::ifstream file( get_config_path( "config.txt" ) ); + if ( file.is_open() ) { + std::string line; + while ( std::getline( file, line ) ) { + const std::size_t eq = line.find( '=' ); + if ( eq == std::string::npos ) { + continue; + } + const std::string key = line.substr( 0, eq ); + const std::string val = line.substr( eq + 1 ); + if ( key == "user_agent" ) { + config.user_agent = val; + } else if ( key == "homepage" ) { + config.homepage = val; + } + } + } + loaded = true; + } + return config; +} + +} // namespace ricochet::net diff --git a/ricochet/src/net/cookie_jar.cc b/ricochet/src/net/cookie_jar.cc new file mode 100644 index 0000000..1328511 --- /dev/null +++ b/ricochet/src/net/cookie_jar.cc @@ -0,0 +1,124 @@ +/* + * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation. + * Copyright (C) 2026 Richard Qin + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +#include "ricochet/net/cookie_jar.hh" +#include "ricochet/net/config.hh" + +#include <fstream> + +namespace ricochet::net { + +void load_cookies_from_file( CookieJar& jar ) +{ + std::ifstream file( get_config_path( "cookies.txt" ) ); + if ( !file.is_open() ) { + return; + } + + std::string line; + while ( std::getline( file, line ) ) { + const std::size_t t1 = line.find( '\t' ); + if ( t1 == std::string::npos ) { + continue; + } + const std::size_t t2 = line.find( '\t', t1 + 1 ); + if ( t2 != std::string::npos ) { + jar[line.substr( 0, t1 )][line.substr( t1 + 1, t2 - t1 - 1 )] = line.substr( t2 + 1 ); + } + } +} + +CookieJar& get_cookie_jar() +{ + static CookieJar jar; + static bool initialized = false; + if ( !initialized ) { + load_cookies_from_file( jar ); + initialized = true; + } + return jar; +} + +void save_cookies_to_file( const CookieJar& jar ) +{ + std::ofstream file( get_config_path( "cookies.txt" ), std::ios::trunc ); + if ( !file.is_open() ) { + return; + } + + for ( const auto& [domain, cookies] : jar ) { + for ( const auto& [k, v] : cookies ) { + file << domain << '\t' << k << '\t' << v << '\n'; + } + } +} + +void update_cookies( const std::string& raw_response, // NOLINT(bugprone-easily-swappable-parameters) + const std::string& host ) +{ + std::size_t pos = 0; + bool updated = false; + while ( ( pos = raw_response.find( "Set-Cookie: ", pos ) ) != std::string::npos ) { + pos += 12; + const std::size_t end_pos = raw_response.find( '\r', pos ); + if ( end_pos == std::string::npos ) { + break; + } + + const std::string cookie_line = raw_response.substr( pos, end_pos - pos ); + const std::size_t semi_pos = cookie_line.find( ';' ); + const std::string cookie_pair + = ( semi_pos == std::string::npos ) ? cookie_line : cookie_line.substr( 0, semi_pos ); + + const std::size_t eq_pos = cookie_pair.find( '=' ); + if ( eq_pos != std::string::npos ) { + const std::string key = cookie_pair.substr( 0, eq_pos ); + const std::string val = cookie_pair.substr( eq_pos + 1 ); + get_cookie_jar()[host][key] = val; + updated = true; + } + } + if ( updated ) { + save_cookies_to_file( get_cookie_jar() ); + } +} + +std::string build_cookie_header( const std::string& host ) +{ + const auto& jar = get_cookie_jar(); + auto it = jar.find( host ); + if ( it == jar.end() || it->second.empty() ) { + return ""; + } + + std::string header = "Cookie: "; + bool first = true; + for ( const auto& [k, v] : it->second ) { + if ( !first ) { + header += "; "; + } + header += k; + header += "="; + header += v; + first = false; + } + header += "\r\n"; + return header; +} + +} // namespace ricochet::net diff --git a/ricochet/src/net/http_client.cc b/ricochet/src/net/http_client.cc new file mode 100644 index 0000000..4a10c02 --- /dev/null +++ b/ricochet/src/net/http_client.cc @@ -0,0 +1,212 @@ +/* + * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation. + * Copyright (C) 2026 Richard Qin + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +#include "ricochet/net/http_client.hh" + +#include "ricochet/net/config.hh" +#include "ricochet/net/cookie_jar.hh" +#include "ricochet/net/http_utils.hh" +#include "ricochet/net/ssl_socket.hh" +#include "ricochet/net/sys/address.hh" +#include "ricochet/net/sys/socket.hh" +#include "ricochet/net/url_parser.hh" + +#include <cstddef> +#include <format> +#include <string> +#include <string_view> + +namespace ricochet::net { + +namespace { + +struct FetchResult +{ + std::string raw_response {}; + std::string error {}; +}; + +FetchResult do_network_request( const std::string& host, + const std::string& service, // NOLINT(bugprone-easily-swappable-parameters) + const std::string& path, // NOLINT(bugprone-easily-swappable-parameters) + bool is_https, + const std::string& method, // NOLINT(bugprone-easily-swappable-parameters) + const std::string& body, + const std::string& cookie_header ) // NOLINT(bugprone-easily-swappable-parameters) +{ + FetchResult res; + try { + const Address server_addr( host, service ); + std::string request = std::format( "{} {} HTTP/1.1\r\n" + "Host: {}\r\n" + "User-Agent: {}\r\n" + "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\r\n" + "Accept-Language: en-US,en;q=0.9\r\n" + "DNT: 1\r\n" + "{}" + "Connection: close\r\n", + method, + path, + host, + get_browser_config().user_agent, + cookie_header ); + if ( !body.empty() ) { + request += std::format( "Content-Length: {}\r\n" + "Content-Type: application/x-www-form-urlencoded\r\n", + body.length() ); + } + request += "\r\n" + body; + + if ( is_https ) { + SSLSocket ssl_socket; + ssl_socket.connect( server_addr, host ); + for ( size_t bw = 0; bw < request.size(); ) { + bw += ssl_socket.write( request.substr( bw ) ); + } + while ( !ssl_socket.eof() ) { + std::string buffer; + ssl_socket.read( buffer ); + res.raw_response += buffer; + } + } else { + TCPSocket tcp_socket; + tcp_socket.connect( server_addr ); + for ( size_t bw = 0; bw < request.size(); ) { + bw += tcp_socket.write( request.substr( bw ) ); + } + while ( !tcp_socket.eof() ) { + std::string buffer; + tcp_socket.read( buffer ); + res.raw_response += buffer; + } + } + } catch ( const std::exception& e ) { + res.error = std::string( "Network Error: " ) + e.what(); + } + return res; +} + +void parse_response_headers( const std::string& raw_response, // NOLINT(bugprone-easily-swappable-parameters) + const std::string& host, // NOLINT(bugprone-easily-swappable-parameters) + HttpResponse& response, + std::string& headers ) +{ + const auto header_end = raw_response.find( "\r\n\r\n" ); + headers = ( header_end != std::string::npos ) ? raw_response.substr( 0, header_end ) : raw_response; + update_cookies( headers, host ); + + if ( header_end != std::string::npos ) { + response.body = raw_response.substr( header_end + 4 ); + std::size_t ct_pos = headers.find( "Content-Type: " ); + if ( ct_pos == std::string::npos ) { + ct_pos = headers.find( "content-type: " ); + } + if ( ct_pos != std::string::npos ) { + ct_pos += 14; + const std::size_t end_pos = headers.find( '\r', ct_pos ); + if ( end_pos != std::string::npos ) { + response.content_type = headers.substr( ct_pos, end_pos - ct_pos ); + } + } + if ( raw_response.size() >= 12 ) { + try { + response.status_code = std::stoi( raw_response.substr( 9, 3 ) ); + } catch ( ... ) { + response.status_code = 0; + } + } + } else { + response.body = raw_response; + response.status_code = 0; + } +} + +void sanitize_response_body( HttpResponse& response, const std::string& raw_response ) +{ + if ( raw_response.contains( "Transfer-Encoding: chunked" ) ) { + response.body = dechunk( response.body ); + } + std::string ct_lower = response.content_type; + for ( char& c : ct_lower ) { + if ( c >= 'A' && c <= 'Z' ) { + c = static_cast<char>( c + 32 ); + } + } + + if ( ct_lower.starts_with( "text/" ) || ct_lower.contains( "xml" ) ) { + response.body = remove_html_comments( response.body ); + response.body = remove_tag_blocks( response.body, "script" ); + response.body = remove_tag_blocks( response.body, "style" ); + } +} + +} // namespace + +std::expected<HttpResponse, std::string> +HttpClient::fetch( // NOLINT(readability-convert-member-functions-to-static) + std::string_view url, // NOLINT(bugprone-easily-swappable-parameters) + std::string_view method, // NOLINT(bugprone-easily-swappable-parameters) + std::string_view body ) const // NOLINT(bugprone-easily-swappable-parameters) +{ + std::string current_url( url ); + std::string current_method( method ); + std::string current_body( body ); + + for ( int redirect_count = 0; redirect_count < 5; ++redirect_count ) { + const auto parsed_url = parse_url( current_url ); + if ( !parsed_url.has_value() ) { + return std::unexpected( parsed_url.error() ); + } + + const auto [host, path] = parsed_url.value(); + const bool is_https = current_url.starts_with( "https://" ); + const std::string service = is_https ? "https" : "http"; + + const std::string cookie_header = build_cookie_header( host ); + + const FetchResult net_res + = do_network_request( host, service, path, is_https, current_method, current_body, cookie_header ); + if ( !net_res.error.empty() ) { + return std::unexpected( net_res.error ); + } + const std::string& raw_response = net_res.raw_response; + + HttpResponse response; + std::string headers; + parse_response_headers( raw_response, host, response, headers ); + + if ( response.status_code >= 300 && response.status_code < 400 ) { + const std::string next_url = extract_redirect_url( headers, is_https, host ); + if ( !next_url.empty() ) { + current_url = next_url; + if ( response.status_code == 301 || response.status_code == 302 || response.status_code == 303 ) { + current_method = "GET"; + current_body.clear(); + } + continue; + } + } + + sanitize_response_body( response, raw_response ); + return response; + } + + return std::unexpected( "Too many redirects" ); +} + +} // namespace ricochet::net
\ No newline at end of file diff --git a/ricochet/src/net/http_utils.cc b/ricochet/src/net/http_utils.cc new file mode 100644 index 0000000..98f6dae --- /dev/null +++ b/ricochet/src/net/http_utils.cc @@ -0,0 +1,93 @@ +/* + * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation. + * Copyright (C) 2026 Richard Qin + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +#include "ricochet/net/http_utils.hh" + +#include <string> +#include <string_view> + +namespace ricochet::net { + +std::string dechunk( std::string_view body ) +{ + std::string result; + std::size_t pos = 0; + while ( pos < body.size() ) { + auto line_end = body.find( "\r\n", pos ); + if ( line_end == std::string_view::npos ) { + break; + } + + const std::string len_str( body.substr( pos, line_end - pos ) ); + unsigned int chunk_size = 0; + try { + chunk_size = std::stoul( len_str, nullptr, 16 ); + } catch ( ... ) { + break; + } + + if ( chunk_size == 0 ) { + break; + } + + pos = line_end + 2; + if ( pos + chunk_size > body.size() ) { + break; + } + result.append( body.substr( pos, chunk_size ) ); + + pos += chunk_size + 2; + } + return result; +} + +std::string remove_html_comments( std::string html ) +{ + std::size_t start_pos = 0; + while ( ( start_pos = html.find( "<!--", start_pos ) ) != std::string::npos ) { + auto end_pos = html.find( "-->", start_pos ); + if ( end_pos != std::string::npos ) { + html.erase( start_pos, end_pos - start_pos + 3 ); + } else { + html.erase( start_pos ); + break; + } + } + return html; +} + +std::string remove_tag_blocks( std::string html, // NOLINT(bugprone-easily-swappable-parameters) + const std::string& tag_name ) +{ + const std::string open_tag = "<" + tag_name; + const std::string close_tag = "</" + tag_name + ">"; + std::size_t start_pos = 0; + + while ( ( start_pos = html.find( open_tag, start_pos ) ) != std::string::npos ) { + const std::size_t end_pos = html.find( close_tag, start_pos ); + if ( end_pos != std::string::npos ) { + html.erase( start_pos, end_pos - start_pos + close_tag.length() ); + } else { + html.erase( start_pos ); + break; + } + } + return html; +} + +} // namespace ricochet::net diff --git a/ricochet/src/net/ssl_socket.cc b/ricochet/src/net/ssl_socket.cc new file mode 100644 index 0000000..a31b564 --- /dev/null +++ b/ricochet/src/net/ssl_socket.cc @@ -0,0 +1,118 @@ +/* + * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation. + * Copyright (C) 2026 Richard Qin + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +#include "ricochet/net/ssl_socket.hh" +#include <iostream> +#include <openssl/err.h> +#include <openssl/ssl.h> +#include <stdexcept> + +namespace ricochet::net { + +// --- RAII Deleters --- + +void SslCtxDeleter::operator()( SSL_CTX* ptr ) const +{ + if ( ptr != nullptr ) { + SSL_CTX_free( ptr ); + } +} + +void SslDeleter::operator()( SSL* ptr ) const +{ + if ( ptr != nullptr ) { + SSL_free( ptr ); + } +} + +// SSLSocket Implementation + +SSLSocket::SSLSocket() +{ + const SSL_METHOD* method = TLS_client_method(); + ctx_.reset( SSL_CTX_new( method ) ); + if ( !ctx_ ) { + throw std::runtime_error( "Failed to create SSL_CTX" ); + } + + if ( SSL_CTX_set_default_verify_paths( ctx_.get() ) != 1 ) { + throw std::runtime_error( "Failed to load default CA certificates" ); + } +} + +void SSLSocket::connect( const Address& address, const std::string& hostname ) +{ + // TCP Connect + underlying_socket_.connect( address ); + + ssl_.reset( SSL_new( ctx_.get() ) ); + if ( !ssl_ ) { + throw std::runtime_error( "Failed to create SSL object" ); + } + + // Setup SNI (Server Name Indication) + if ( SSL_set_tlsext_host_name( ssl_.get(), hostname.c_str() ) != 1 ) { + // Some old servers do not support SNI, here we can ignore the error or throw an exception, depending on the + // requirements + std::cerr << "Warning: Failed to set SNI" << '\n'; + } + + SSL_set_fd( ssl_.get(), underlying_socket_.fd_num() ); + + if ( SSL_connect( ssl_.get() ) != 1 ) { + throw std::runtime_error( "TLS Handshake failed!" ); + } +} + +std::size_t SSLSocket::write( std::string_view data ) +{ + if ( !ssl_ ) { + return 0; + } + + const int bytes_written = SSL_write( ssl_.get(), data.data(), static_cast<int>( data.size() ) ); + if ( bytes_written <= 0 ) { + throw std::runtime_error( "SSL write failed" ); + } + return static_cast<std::size_t>( bytes_written ); +} + +void SSLSocket::read( std::string& buffer ) +{ + if ( !ssl_ ) { + return; + } + + std::array<char, 4096> buf {}; + // Read decrypted data from the encrypted channel + const int bytes_read = SSL_read( ssl_.get(), buf.data(), static_cast<int>( buf.size() ) ); + + if ( bytes_read > 0 ) { + buffer.append( buf.data(), static_cast<std::size_t>( bytes_read ) ); + } else { + // Read 0 or negative, representing the server closed the connection (EOF) + eof_ = true; + } +} + +bool SSLSocket::eof() const +{ + return eof_; +} + +} // namespace ricochet::net
\ No newline at end of file diff --git a/ricochet/src/net/sys/address.cc b/ricochet/src/net/sys/address.cc new file mode 100644 index 0000000..48f22ac --- /dev/null +++ b/ricochet/src/net/sys/address.cc @@ -0,0 +1,207 @@ +/** + * --------------------------------------------------------------------------- + * @notice This file was adapted from the Minnow networking framework, + * originally developed for Stanford University's CS144 course. + * It has been incorporated into the Ricochet project as foundational + * system utility code. + * --------------------------------------------------------------------------- + */ + +#include "ricochet/net/sys/address.hh" + +#include "ricochet/net/sys/exception.hh" + +#include <array> +#include <cstring> +#include <linux/if_packet.h> +#include <memory> +#include <netdb.h> +#include <stdexcept> +#include <sys/socket.h> +#include <system_error> + +using namespace std; + +//! Converts Raw to `sockaddr *`. +Address::Raw::operator sockaddr*() +{ + return reinterpret_cast<sockaddr*>( &storage ); // NOLINT(*-reinterpret-cast) +} + +//! Converts Raw to `const sockaddr *`. +Address::Raw::operator const sockaddr*() const +{ + return reinterpret_cast<const sockaddr*>( &storage ); // NOLINT(*-reinterpret-cast) +} + +//! \param[in] addr points to a raw socket address +//! \param[in] size is `addr`'s length +Address::Address( const sockaddr* addr, const size_t size ) : size_( size ) +{ + // make sure proposed sockaddr can fit + if ( size > sizeof( address_.storage ) ) { + throw runtime_error( "invalid sockaddr size" ); + } + + memcpy( &address_.storage, addr, size ); +} + +//! Error category for getaddrinfo and getnameinfo failures. +class gai_error_category : public error_category +{ +public: + //! The name of the wrapped error + const char* name() const noexcept override { return "gai_error_category"; } + //! \brief An error message + //! \param[in] return_value the error return value from [getaddrinfo(3)](\ref man3::getaddrinfo) + //! or [getnameinfo(3)](\ref man3::getnameinfo) + string message( const int return_value ) const noexcept override { return gai_strerror( return_value ); } +}; + +//! \param[in] node is the hostname or dotted-quad address +//! \param[in] service is the service name or numeric string +//! \param[in] hints are criteria for resolving the supplied name +Address::Address( const string& node, const string& service, const addrinfo& hints ) : size_() +{ + // prepare for the answer + addrinfo* resolved_address = nullptr; + + // look up the name or names + const int gai_ret = getaddrinfo( node.c_str(), service.c_str(), &hints, &resolved_address ); + if ( gai_ret != 0 ) { + throw tagged_error( gai_error_category(), "getaddrinfo(" + node + ", " + service + ")", gai_ret ); + } + + // if success, should always have at least one entry + if ( resolved_address == nullptr ) { + throw runtime_error( "getaddrinfo returned successfully but with no results" ); + } + + // put resolved_address in a wrapper so it will get freed if we have to throw an exception + auto addrinfo_deleter = []( addrinfo* const x ) { freeaddrinfo( x ); }; + unique_ptr<addrinfo, decltype( addrinfo_deleter )> wrapped_address( resolved_address, move( addrinfo_deleter ) ); + + // assign to our private members (making sure size fits) + *this = Address( wrapped_address->ai_addr, wrapped_address->ai_addrlen ); +} + +//! \brief Build a `struct addrinfo` containing hints for [getaddrinfo(3)](\ref man3::getaddrinfo) +//! \param[in] ai_flags is the value of the `ai_flags` field in the [struct addrinfo](\ref man3::getaddrinfo) +//! \param[in] ai_family is the value of the `ai_family` field in the [struct addrinfo](\ref +//! man3::getaddrinfo) +namespace { +inline addrinfo make_hints( int ai_flags, int ai_family ) // NOLINT(*-swappable-parameters) +{ + addrinfo hints {}; // value initialized to all zeros + hints.ai_flags = ai_flags; + hints.ai_family = ai_family; + return hints; +} +} // namespace + +//! \param[in] hostname to resolve +//! \param[in] service name (from `/etc/services`, e.g., "http" is port 80) +Address::Address( const string& hostname, const string& service ) + : Address( hostname, service, make_hints( AI_ALL, AF_INET ) ) +{} + +//! \param[in] ip address as a dotted quad ("1.1.1.1") +//! \param[in] port number +Address::Address( const string& ip, const uint16_t port ) + // tell getaddrinfo that we don't want to resolve anything + : Address( ip, ::to_string( port ), make_hints( AI_NUMERICHOST | AI_NUMERICSERV, AF_INET ) ) +{} + +// accessors +pair<string, uint16_t> Address::ip_port() const +{ + if ( address_.storage.ss_family != AF_INET and address_.storage.ss_family != AF_INET6 ) { + throw runtime_error( "Address::ip_port() called on non-Internet address" ); + } + + array<char, NI_MAXHOST> ip {}; + array<char, NI_MAXSERV> port {}; + + const int gni_ret = getnameinfo( static_cast<const sockaddr*>( address_ ), + size_, + ip.data(), + ip.size(), + port.data(), + port.size(), + NI_NUMERICHOST | NI_NUMERICSERV ); + if ( gni_ret != 0 ) { + throw tagged_error( gai_error_category(), "getnameinfo", gni_ret ); + } + + return { ip.data(), stoi( port.data() ) }; +} + +string Address::to_string() const +{ + if ( address_.storage.ss_family == AF_INET or address_.storage.ss_family == AF_INET6 ) { + const auto ip_and_port = ip_port(); + return ip_and_port.first + ":" + ::to_string( ip_and_port.second ); + } + + return "(non-Internet address)"; +} + +uint32_t Address::ipv4_numeric() const +{ + if ( address_.storage.ss_family != AF_INET or size_ != sizeof( sockaddr_in ) ) { + throw runtime_error( "ipv4_numeric called on non-IPV4 address" ); + } + + sockaddr_in ipv4_addr {}; + memcpy( &ipv4_addr, &address_.storage, size_ ); + + return be32toh( ipv4_addr.sin_addr.s_addr ); +} + +Address Address::from_ipv4_numeric( const uint32_t ip_address ) +{ + sockaddr_in ipv4_addr {}; + ipv4_addr.sin_family = AF_INET; + ipv4_addr.sin_addr.s_addr = htobe32( ip_address ); + + return { reinterpret_cast<sockaddr*>( &ipv4_addr ), sizeof( ipv4_addr ) }; // NOLINT(*-reinterpret-cast) +} + +// equality +bool Address::operator==( const Address& other ) const +{ + if ( size_ != other.size_ ) { + return false; + } + + return 0 == memcmp( &address_, &other.address_, size_ ); +} + +// address families that correspond to each sockaddr type +template<typename sockaddr_type> +constexpr int sockaddr_family = -1; + +template<> +constexpr int sockaddr_family<sockaddr_in> = AF_INET; + +template<> +constexpr int sockaddr_family<sockaddr_in6> = AF_INET6; + +template<> +constexpr int sockaddr_family<sockaddr_ll> = AF_PACKET; + +// safely cast the address to its underlying sockaddr type +template<typename sockaddr_type> +const sockaddr_type* Address::as() const +{ + const sockaddr* raw { address_ }; + if ( sizeof( sockaddr_type ) < size() or raw->sa_family != sockaddr_family<sockaddr_type> ) { + throw runtime_error( "Address::as() conversion failure" ); + } + + return reinterpret_cast<const sockaddr_type*>( raw ); // NOLINT(*-reinterpret-cast) +} + +template const sockaddr_in* Address::as<sockaddr_in>() const; +template const sockaddr_in6* Address::as<sockaddr_in6>() const; +template const sockaddr_ll* Address::as<sockaddr_ll>() const; diff --git a/ricochet/src/net/sys/file_descriptor.cc b/ricochet/src/net/sys/file_descriptor.cc new file mode 100644 index 0000000..b765308 --- /dev/null +++ b/ricochet/src/net/sys/file_descriptor.cc @@ -0,0 +1,213 @@ +/** + * --------------------------------------------------------------------------- + * @notice This file was adapted from the Minnow networking framework, + * originally developed for Stanford University's CS144 course. + * It has been incorporated into the Ricochet project as foundational + * system utility code. + * --------------------------------------------------------------------------- + */ + +#include "ricochet/net/sys/file_descriptor.hh" + +#include "ricochet/net/sys/exception.hh" + +#include <fcntl.h> +#include <iostream> +#include <stdexcept> +#include <sys/types.h> +#include <sys/uio.h> +#include <unistd.h> + +using namespace std; + +size_t FileDescriptor::FDWrapper::CheckFDSystemCall( string_view what, ssize_t return_value ) const +{ + if ( return_value >= 0 ) { + return return_value; + } + + if ( non_blocking_ and ( errno == EAGAIN or errno == EWOULDBLOCK or errno == EINPROGRESS ) ) { + return 0; + } + + throw unix_error { what }; +} + +size_t FileDescriptor::FDWrapper::CheckRead( string_view what, ssize_t return_value ) +{ + if ( return_value == 0 ) { + eof_ = true; + } + + return CheckFDSystemCall( what, return_value ); +} + +size_t FileDescriptor::CheckFDSystemCall( string_view what, ssize_t return_value ) const +{ + if ( not internal_fd_ ) { + throw runtime_error( "internal error: missing internal_fd_" ); + } + return internal_fd_->CheckFDSystemCall( what, return_value ); +} + +size_t FileDescriptor::CheckRead( string_view what, ssize_t return_value ) +{ + if ( not internal_fd_ ) { + throw runtime_error( "internal error: missing internal_fd_" ); + } + return internal_fd_->CheckRead( what, return_value ); +} + +// fd is the file descriptor number returned by [open(2)](\ref man2::open) or similar +FileDescriptor::FDWrapper::FDWrapper( int fd ) : fd_( fd ) +{ + if ( fd < 0 ) { + throw runtime_error( "invalid fd number:" + to_string( fd ) ); + } + + const int flags = CheckSystemCall( "fcntl", fcntl( fd, F_GETFL ) ); // NOLINT(*-vararg) + non_blocking_ = flags & O_NONBLOCK; // NOLINT(*-bitwise) +} + +void FileDescriptor::FDWrapper::close() +{ + CheckSystemCall( "close", ::close( fd_ ) ); + eof_ = closed_ = true; +} + +FileDescriptor::FDWrapper::~FDWrapper() +{ + try { + if ( closed_ ) { + return; + } + close(); + } catch ( const exception& e ) { + // don't throw an exception from the destructor + cerr << "Exception destructing FDWrapper: " << e.what() << "\n"; + } +} + +// fd is the file descriptor number returned by [open(2)](\ref man2::open) or similar +FileDescriptor::FileDescriptor( int fd ) : internal_fd_( make_shared<FDWrapper>( fd ) ) {} + +// Private constructor used by duplicate() +FileDescriptor::FileDescriptor( shared_ptr<FDWrapper> other_shared_ptr ) : internal_fd_( move( other_shared_ptr ) ) +{} + +// returns a copy of this FileDescriptor +FileDescriptor FileDescriptor::duplicate() const +{ + return FileDescriptor { internal_fd_ }; +} + +// Read into a single buffer of given length (which if empty will be resized to a reasonable value before reading). +// Afer the read, the buffer will be resized to match whatever was read. +void FileDescriptor::read( string& buffer ) +{ + if ( buffer.empty() ) { + buffer.resize( kReadBufferSize ); + } + + const size_t bytes_read = CheckRead( "read", ::read( fd_num(), buffer.data(), buffer.size() ) ); + register_read(); + + if ( bytes_read > buffer.size() ) { + throw runtime_error( "read() read more than requested" ); + } + + buffer.resize( bytes_read ); +} + +// Read into a vector of buffers (if all empty, the last one will be resized to a reasonable value). +// After the read, the buffers will be resized to match whatever was read. +void FileDescriptor::read( vector<string>& buffers ) +{ + if ( buffers.empty() ) { + throw runtime_error( "FileDescriptor::read called with no buffers" ); + } + + if ( buffers.back().empty() ) { + buffers.back().resize( kReadBufferSize ); + } + + static thread_local vector<iovec> iovecs; + const size_t total_size = to_iovecs( buffers, iovecs ); + + const size_t bytes_read + = CheckRead( "readv", readv( fd_num(), iovecs.data(), static_cast<int>( iovecs.size() ) ) ); + register_read(); + + if ( bytes_read > total_size ) { + throw runtime_error( "read() read more than requested" ); + } + + size_t remaining_size = bytes_read; + for ( auto& buf : buffers ) { + if ( remaining_size >= buf.size() ) { + remaining_size -= buf.size(); + } else { + buf.resize( remaining_size ); + remaining_size = 0; + } + } +} + +void FileDescriptor::write_all( string_view buffer ) +{ + if ( not blocking() ) { + throw runtime_error( "write_all requires a blocking file descriptor" ); + // otherwise, this could call write on a non-blocking fd that isn't "ready" for writing + } + + while ( not buffer.empty() ) { + buffer.remove_prefix( write( buffer ) ); + } +} + +size_t FileDescriptor::write( string_view buffer ) +{ + const size_t bytes_written = CheckFDSystemCall( "write", ::write( fd_num(), buffer.data(), buffer.size() ) ); + register_write(); + + if ( bytes_written == 0 and not buffer.empty() ) { + throw runtime_error( "write returned 0 given non-empty input buffer" ); + } + + if ( bytes_written > buffer.size() ) { + throw runtime_error( "write wrote more than length of input buffer" ); + } + + return bytes_written; +} + +size_t FileDescriptor::write( const std::vector<iovec>& iovecs, size_t total_size ) +{ + const size_t bytes_written + = CheckFDSystemCall( "writev", ::writev( fd_num(), iovecs.data(), static_cast<int>( iovecs.size() ) ) ); + register_write(); + + if ( bytes_written == 0 and total_size != 0 ) { + throw runtime_error( "writev returned 0 given non-empty input buffer" ); + } + + if ( bytes_written > total_size ) { + throw runtime_error( "writev wrote more than length of input buffer" ); + } + + return bytes_written; +} + +void FileDescriptor::set_blocking( bool blocking ) +{ + int flags = CheckSystemCall( "fcntl", fcntl( fd_num(), F_GETFL ) ); // NOLINT(*-vararg) + if ( blocking ) { + flags ^= ( flags & O_NONBLOCK ); // NOLINT(*-bitwise) + } else { + flags |= O_NONBLOCK; // NOLINT(*-bitwise) + } + + CheckSystemCall( "fcntl", fcntl( fd_num(), F_SETFL, flags ) ); // NOLINT(*-vararg) + + internal_fd_->non_blocking_ = not blocking; +} diff --git a/ricochet/src/net/sys/socket.cc b/ricochet/src/net/sys/socket.cc new file mode 100644 index 0000000..701704f --- /dev/null +++ b/ricochet/src/net/sys/socket.cc @@ -0,0 +1,286 @@ +/** + * --------------------------------------------------------------------------- + * @notice This file was adapted from the Minnow networking framework, + * originally developed for Stanford University's CS144 course. + * It has been incorporated into the Ricochet project as foundational + * system utility code. + * --------------------------------------------------------------------------- + */ + +#include "ricochet/net/sys/socket.hh" + +#include "ricochet/net/sys/exception.hh" + +#include <linux/if_packet.h> +#include <stdexcept> +#include <unistd.h> + +using namespace std; + +// default constructor for socket of (subclassed) domain and type +//! \param[in] domain is as described in [socket(7)](\ref man7::socket), probably `AF_INET` or `AF_UNIX` +//! \param[in] type is as described in [socket(7)](\ref man7::socket) +Socket::Socket( const int domain, const int type, const int protocol ) + : FileDescriptor( CheckSystemCall( "socket", socket( domain, type, protocol ) ) ) +{} + +// construct from file descriptor +//! \param[in] fd is the FileDescriptor from which to construct +//! \param[in] domain is `fd`'s domain; throws std::runtime_error if wrong value is supplied +//! \param[in] type is `fd`'s type; throws std::runtime_error if wrong value is supplied +//! \param[in] protocol is `fd`'s protocol; throws std::runtime_error if wrong value is supplied +Socket::Socket( FileDescriptor&& fd, int domain, int type, int protocol ) // NOLINT(*-swappable-parameters) + : FileDescriptor( move( fd ) ) +{ + int actual_value {}; + socklen_t len {}; + + // verify domain + len = getsockopt( SOL_SOCKET, SO_DOMAIN, actual_value ); + if ( ( len != sizeof( actual_value ) ) or ( actual_value != domain ) ) { + throw runtime_error( "socket domain mismatch" ); + } + + // verify type + len = getsockopt( SOL_SOCKET, SO_TYPE, actual_value ); + if ( ( len != sizeof( actual_value ) ) or ( actual_value != type ) ) { + throw runtime_error( "socket type mismatch" ); + } + + // verify protocol + len = getsockopt( SOL_SOCKET, SO_PROTOCOL, actual_value ); + if ( ( len != sizeof( actual_value ) ) or ( actual_value != protocol ) ) { + throw runtime_error( "socket protocol mismatch" ); + } +} + +// get the local or peer address the socket is connected to +//! \param[in] name_of_function is the function to call (string passed to CheckSystemCall()) +//! \param[in] function is a pointer to the function +//! \returns the requested Address +Address Socket::get_address( const string& name_of_function, + const function<int( int, sockaddr*, socklen_t* )>& function ) const +{ + Address::Raw address; + socklen_t size = sizeof( address ); + + CheckSystemCall( name_of_function, function( fd_num(), address, &size ) ); + + return Address { address, size }; +} + +//! \returns the local Address of the socket +Address Socket::local_address() const +{ + return get_address( "getsockname", getsockname ); +} + +//! \returns the socket's peer's Address +Address Socket::peer_address() const +{ + return get_address( "getpeername", getpeername ); +} + +// bind socket to a specified local address (usually to listen/accept) +//! \param[in] address is a local Address to bind +void Socket::bind( const Address& address ) +{ + CheckSystemCall( "bind", ::bind( fd_num(), address.raw(), address.size() ) ); +} + +void Socket::bind_to_device( const string_view device_name ) +{ + setsockopt( SOL_SOCKET, SO_BINDTODEVICE, device_name ); +} + +// connect socket to a specified peer address +//! \param[in] address is the peer's Address +void Socket::connect( const Address& address ) +{ + CheckFDSystemCall( "connect", ::connect( fd_num(), address.raw(), address.size() ) ); +} + +// shut down a socket in the specified way +//! \param[in] how can be `SHUT_RD`, `SHUT_WR`, or `SHUT_RDWR`; see [shutdown(2)](\ref man2::shutdown) +void Socket::shutdown( const int how ) +{ + CheckSystemCall( "shutdown", ::shutdown( fd_num(), how ) ); + switch ( how ) { + case SHUT_RD: + register_read(); + break; + case SHUT_WR: + register_write(); + break; + case SHUT_RDWR: + register_read(); + register_write(); + break; + default: + throw runtime_error( "Socket::shutdown() called with invalid `how`" ); + } +} + +void DatagramSocket::recv( Address& source_address, string& payload ) +{ + if ( payload.empty() ) { + payload.resize( kReadBufferSize ); + } + + Address::Raw raw_source_address; + socklen_t namelen = sizeof( raw_source_address ); + const size_t recv_len = CheckFDSystemCall( + "recvfrom", ::recvfrom( fd_num(), payload.data(), payload.size(), MSG_TRUNC, raw_source_address, &namelen ) ); + register_read(); + + if ( recv_len > payload.size() ) { + throw runtime_error( "recvfrom (oversized datagram of length " + to_string( recv_len ) + ")" ); + } + if ( namelen <= 0 || namelen > sizeof( raw_source_address ) ) { + throw runtime_error( "recvfrom gave invalid namelen" ); + } + + source_address = { raw_source_address, namelen }; +} + +void DatagramSocket::recv( Address& source_address, vector<string>& payloads ) +{ + if ( payloads.empty() ) { + throw runtime_error( "DatagramSocket::recv called with no payload buffers" ); + } + + if ( payloads.back().empty() ) { + payloads.back().resize( kReadBufferSize ); + } + + // set up iovecs + static thread_local vector<iovec> iovecs; + const size_t total_size = to_iovecs( payloads, iovecs ); + + // set up msghdr + Address::Raw raw_source_address; + msghdr message { .msg_name = &raw_source_address, + .msg_namelen = sizeof( raw_source_address ), + .msg_iov = iovecs.data(), + .msg_iovlen = iovecs.size(), + .msg_control = nullptr, + .msg_controllen {}, + .msg_flags {} }; + const size_t recv_len = CheckFDSystemCall( "recvmsg", ::recvmsg( fd_num(), &message, MSG_TRUNC ) ); + register_read(); + if ( recv_len > total_size ) { + throw runtime_error( "recvmsg (oversized datagram of length " + to_string( recv_len ) + ")" ); + } + if ( message.msg_flags & MSG_TRUNC ) { + throw runtime_error( "recvmsg (oversized datagram indicated only by MSG_TRUNC)" ); + } + if ( message.msg_namelen <= 0 || message.msg_namelen > sizeof( raw_source_address ) ) { + throw runtime_error( "recvmsg gave invalid namelen" ); + } + source_address = { raw_source_address, message.msg_namelen }; + + size_t remaining_size = recv_len; + for ( auto& buf : payloads ) { + if ( remaining_size >= buf.size() ) { + remaining_size -= buf.size(); + } else { + buf.resize( remaining_size ); + remaining_size = 0; + } + } +} + +void DatagramSocket::send( string_view payload, const optional<Address>& destination ) +{ + const size_t bytes_sent = CheckFDSystemCall( "sendto", + ::sendto( fd_num(), + payload.data(), + payload.size(), + 0, + destination.has_value() ? destination->raw() : nullptr, + destination.has_value() ? destination->size() : 0 ) ); + register_write(); + if ( bytes_sent != payload.size() ) { + throw runtime_error( "sendto sent some length other than that of payload" ); + } +} + +void DatagramSocket::send( vector<iovec>& iovecs, size_t total_size, const optional<Address>& destination ) +{ + const msghdr message { .msg_name = destination.has_value() // NOLINTNEXTLINE(*-const-cast) + ? static_cast<void*>( const_cast<sockaddr*>( destination->raw() ) ) + : nullptr, + .msg_namelen = destination.has_value() ? destination->size() : 0, + .msg_iov = iovecs.data(), + .msg_iovlen = iovecs.size(), + .msg_control = nullptr, + .msg_controllen {}, + .msg_flags {} }; + const size_t bytes_sent = CheckFDSystemCall( "sendmsg", ::sendmsg( fd_num(), &message, 0 ) ); + register_write(); + if ( bytes_sent != total_size ) { + throw runtime_error( "sendmsg sent some length other than that of payload" ); + } +} + +// mark the socket as listening for incoming connections +//! \param[in] backlog is the number of waiting connections to queue (see [listen(2)](\ref man2::listen)) +void TCPSocket::listen( const int backlog ) +{ + CheckSystemCall( "listen", ::listen( fd_num(), backlog ) ); +} + +// accept a new incoming connection +//! \returns a new TCPSocket connected to the peer. +//! \note This function blocks until a new connection is available +TCPSocket TCPSocket::accept() +{ + register_read(); + return TCPSocket( FileDescriptor( CheckSystemCall( "accept", ::accept( fd_num(), nullptr, nullptr ) ) ) ); +} + +// get socket option +template<typename option_type> +socklen_t Socket::getsockopt( const int level, const int option, option_type& option_value ) const +{ + socklen_t optlen = sizeof( option_value ); + CheckSystemCall( "getsockopt", ::getsockopt( fd_num(), level, option, &option_value, &optlen ) ); + return optlen; +} + +// set socket option +//! \param[in] level The protocol level at which the argument resides +//! \param[in] option A single option to set +//! \param[in] option_value The value to set +//! \details See [setsockopt(2)](\ref man2::setsockopt) for details. +template<typename option_type> +void Socket::setsockopt( const int level, const int option, const option_type& option_value ) +{ + CheckSystemCall( "setsockopt", ::setsockopt( fd_num(), level, option, &option_value, sizeof( option_value ) ) ); +} + +// setsockopt with size only known at runtime +void Socket::setsockopt( const int level, const int option, const string_view option_val ) +{ + CheckSystemCall( "setsockopt", ::setsockopt( fd_num(), level, option, option_val.data(), option_val.size() ) ); +} + +// allow local address to be reused sooner, at the cost of some robustness +//! \note Using `SO_REUSEADDR` may reduce the robustness of your application +void Socket::set_reuseaddr() +{ + setsockopt( SOL_SOCKET, SO_REUSEADDR, int { true } ); +} + +void Socket::throw_if_error() const +{ + int socket_error = 0; + const socklen_t len = getsockopt( SOL_SOCKET, SO_ERROR, socket_error ); + if ( len != sizeof( socket_error ) ) { + throw runtime_error( "unexpected length from getsockopt: " + to_string( len ) ); + } + + if ( socket_error ) { + throw unix_error( "socket error", socket_error ); + } +} diff --git a/ricochet/src/net/url_parser.cc b/ricochet/src/net/url_parser.cc new file mode 100644 index 0000000..95e86ef --- /dev/null +++ b/ricochet/src/net/url_parser.cc @@ -0,0 +1,87 @@ +/* + * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation. + * Copyright (C) 2026 Richard Qin + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +#include "ricochet/net/url_parser.hh" + +#include <cctype> + +namespace ricochet::net { + +std::expected<ParsedUrl, std::string> parse_url( std::string_view url ) +{ + std::string_view prefix = "http://"; + if ( url.starts_with( "https://" ) ) { + prefix = "https://"; + } else if ( url.starts_with( "http://" ) ) { + prefix = "http://"; + } else { + return std::unexpected( "Only 'http://' or 'https://' URLs are supported." ); + } + + url.remove_prefix( prefix.length() ); + + const auto sep_pos = url.find_first_of( "/?#" ); + + ParsedUrl result; + if ( sep_pos == std::string_view::npos ) { + result.host = std::string( url ); + result.path = "/"; + } else { + result.host = std::string( url.substr( 0, sep_pos ) ); + std::string_view path_view = url.substr( sep_pos ); + const auto hash_pos = path_view.find( '#' ); + if ( hash_pos != std::string_view::npos ) { + path_view = path_view.substr( 0, hash_pos ); + } + result.path = std::string( path_view ); + if ( result.path.empty() || result.path[0] != '/' ) { + result.path.insert( 0, "/" ); + } + } + return result; +} + +std::string extract_redirect_url( const std::string& raw_response, bool is_https, const std::string& host ) +{ + auto loc_pos = raw_response.find( "Location: " ); + if ( loc_pos == std::string::npos ) { + loc_pos = raw_response.find( "location: " ); + } + if ( loc_pos != std::string::npos ) { + loc_pos += 10; + auto end_pos = raw_response.find( '\r', loc_pos ); + if ( end_pos == std::string::npos ) { + end_pos = raw_response.find( '\n', loc_pos ); + } + if ( end_pos != std::string::npos ) { + std::string next_url = raw_response.substr( loc_pos, end_pos - loc_pos ); + while ( !next_url.empty() && std::isspace( static_cast<unsigned char>( next_url.front() ) ) ) { + next_url.erase( 0, 1 ); + } + if ( next_url.starts_with( "//" ) ) { + next_url = ( is_https ? "https:" : "http:" ) + next_url; + } else if ( next_url.starts_with( "/" ) ) { + next_url = ( is_https ? "https://" : "http://" ) + host + next_url; + } + return next_url; + } + } + return ""; +} + +} // namespace ricochet::net |
