407 lines
18 KiB
C++
407 lines
18 KiB
C++
#include <atomic>
|
|
#include <cassert>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <functional>
|
|
#include <string>
|
|
#include <vector>
|
|
using String = std::string;
|
|
constexpr int HTTP_GET=0, HTTP_POST=1, U_FLASH=0, U_SPIFFS=100;
|
|
constexpr unsigned UPDATE_SIZE_UNKNOWN=~0U;
|
|
constexpr int pdPASS=1;
|
|
constexpr int ERR_OK=0;
|
|
#define async_tcp_log_d(...) ((void)0)
|
|
#define pdMS_TO_TICKS(value) (value)
|
|
const char ELEGANT_HTML[] = "page";
|
|
const unsigned ELEGANT_HTML_SIZE=sizeof(ELEGANT_HTML);
|
|
int Serial=0;
|
|
bool in_network_callback=false;
|
|
struct Esp {
|
|
int restarts=0;
|
|
void restart() { assert(!in_network_callback); ++restarts; }
|
|
} ESP;
|
|
void yield() { assert(!in_network_callback); }
|
|
void delay(unsigned) { assert(!in_network_callback); }
|
|
void vTaskDelay(unsigned) { assert(!in_network_callback); }
|
|
void vTaskDelete(void*) {}
|
|
using Task = void (*)(void*);
|
|
Task pending_task=nullptr;
|
|
bool allow_task=true;
|
|
int xTaskCreate(Task task, const char*, unsigned, void*, unsigned, void*) {
|
|
if (!allow_task) return 0;
|
|
assert(!pending_task); pending_task=task; return pdPASS;
|
|
}
|
|
@REBOOT_STATE@
|
|
@UPLOAD_STATE@
|
|
// Independent MD5 vectors: eight zero bytes, and that payload with byte zero
|
|
// changed to one. The fake models Update's lifecycle rather than its digest
|
|
// algorithm; both vectors are fixed independently of the uploader callbacks.
|
|
const char fixture_md5[]="7dea362b3fac8e00956a4952a3d4f474";
|
|
const char fixture_corrupt_md5[]="33cdeccccebe80329f1fdbee7f5874cb";
|
|
struct Parameter { String text=fixture_md5; String value() { return text; } };
|
|
struct Response {
|
|
int code;
|
|
String body;
|
|
void addHeader(const char*, const char*) {}
|
|
};
|
|
unsigned fixture_now=0;
|
|
struct Client {
|
|
unsigned _rx_timeout=0, _rx_last_packet=fixture_now;
|
|
bool closed=false;
|
|
std::vector<unsigned> timeout_changes;
|
|
std::function<void()> disconnected;
|
|
void setRxTimeout(unsigned seconds) { _rx_timeout=seconds; timeout_changes.push_back(seconds); }
|
|
void received() { _rx_last_packet=fixture_now; }
|
|
void _close() { closed=true; if (disconnected) disconnected(); }
|
|
int poll() {
|
|
const unsigned now=fixture_now;
|
|
@RX_TIMEOUT@
|
|
return ERR_OK;
|
|
}
|
|
};
|
|
void accept(Client* c) {
|
|
@ACCEPT_TIMEOUT@
|
|
}
|
|
struct Request {
|
|
void* _tempObject=nullptr;
|
|
bool md5_present=true, authorized=true, sent=false;
|
|
int code=0;
|
|
String body;
|
|
Response* queued_response=nullptr;
|
|
std::function<void()> disconnected;
|
|
Parameter md5;
|
|
Client connection;
|
|
Request() { accept(&connection); }
|
|
~Request() { free(_tempObject); delete queued_response; }
|
|
Client* client() { return &connection; }
|
|
bool authenticate(const char*, const char*) { return authorized; }
|
|
void requestAuthentication() { send(401,"text/plain","unauthorized"); }
|
|
bool hasParam(const char*, bool) { return md5_present; }
|
|
Parameter* getParam(const char*, bool) { return &md5; }
|
|
Response* beginResponse(int code, const char*, const char* text) { return new Response{code,text}; }
|
|
Response* beginResponse_P(int code, const char*, const char* text, unsigned) { return new Response{code,text}; }
|
|
void send(Response* response) { delete queued_response; queued_response=response; code=response->code; body=response->body; }
|
|
void send(int status, const char*, const char* text) { send(new Response{status,text}); }
|
|
void send(int status, const char*, const String& text) { send(new Response{status,text}); }
|
|
Response* getResponse() const { return queued_response; }
|
|
void flush_response() { assert(queued_response); sent=true; connection.setRxTimeout(0); }
|
|
bool isSent() const { return sent; }
|
|
void onDisconnect(std::function<void()> fn) { disconnected=fn; connection.disconnected=fn; }
|
|
};
|
|
using AsyncWebServerRequest=Request;
|
|
static AsyncWebServerRequest* ota_upload_owner=nullptr;
|
|
using AsyncWebServerResponse=Response;
|
|
struct AsyncWebServer {
|
|
std::function<void(Request*)> completion;
|
|
std::function<void(Request*,String,size_t,uint8_t*,size_t,bool)> upload;
|
|
template<class F> void on(const char*, int, F) {}
|
|
template<class F,class U> void on(const char*, int method, F callback, U chunks) {
|
|
assert(method==HTTP_POST); completion=callback; upload=chunks;
|
|
}
|
|
};
|
|
struct UpdateFake {
|
|
bool error=false, running=false, fail_begin=false, fail_end=false, fail_write=false;
|
|
int aborts=0, writes=0, begins=0, md5_checks=0, ends=0, boot_commits=0;
|
|
String target_md5;
|
|
std::vector<uint8_t> received;
|
|
bool hasError() const { return error; }
|
|
bool setMD5(const char* md5) {
|
|
// Arduino-ESP32 2.0.17 checks length here, and checks content at end().
|
|
if (std::strlen(md5)!=32) return false;
|
|
target_md5=md5;
|
|
return true;
|
|
}
|
|
bool begin(unsigned, int) {
|
|
++begins;
|
|
if (running) return false;
|
|
// Pinned UpdateClass::begin() clears a digest set before initialization.
|
|
target_md5.clear(); received.clear();
|
|
error=fail_begin; running=!error;
|
|
return running;
|
|
}
|
|
size_t write(uint8_t* data, size_t size) {
|
|
++writes;
|
|
if (error || !running) return 0;
|
|
if (fail_write) { error=true; running=false; return size-1; }
|
|
received.insert(received.end(),data,data+size);
|
|
return size;
|
|
}
|
|
bool end(bool) {
|
|
++ends;
|
|
if (error || !running) return false;
|
|
if (fail_end) { error=true; running=false; return false; }
|
|
assert(received.size()==8);
|
|
const std::vector<uint8_t> normal(8,0);
|
|
auto corrupted=normal; corrupted[0]=1;
|
|
assert(received==normal || received==corrupted);
|
|
const String actual_md5=received==normal ? fixture_md5 : fixture_corrupt_md5;
|
|
// Match Arduino: an empty target skips validation. Success assertions below
|
|
// require a real comparison, catching the original pre-begin ordering.
|
|
if (!target_md5.empty()) {
|
|
++md5_checks;
|
|
if (target_md5!=actual_md5) { error=true; running=false; return false; }
|
|
}
|
|
running=false;
|
|
// Pinned UpdateClass::_verifyEnd() activates the boot partition when end()
|
|
// succeeds. Track that irreversible effect separately from file receipt.
|
|
++boot_commits;
|
|
return true;
|
|
}
|
|
void abort() { error=true; running=false; ++aborts; }
|
|
// A connected USB host which does not drain TX can block raw Serial. Every
|
|
// upload error path must complete without attempting that raw print.
|
|
void printError(int) { assert(false && "Raw USB printing can block OTA callbacks"); }
|
|
} Update;
|
|
struct AsyncElegantOtaClass {
|
|
AsyncWebServer* _server=nullptr;
|
|
bool _authRequired=false;
|
|
String _username, _password, _id="fixture";
|
|
void begin(AsyncWebServer*, const char*, const char*);
|
|
void restart();
|
|
bool setEnabled(bool);
|
|
};
|
|
@BEGIN@
|
|
@RESTART@
|
|
|
|
int main() {
|
|
AsyncWebServer server;
|
|
AsyncElegantOtaClass ota;
|
|
ota.begin(&server,"","");
|
|
auto complete=[&](Request& request) {
|
|
in_network_callback=true;
|
|
server.completion(&request); request.flush_response();
|
|
in_network_callback=false;
|
|
};
|
|
auto upload=[&](Request& request) {
|
|
uint8_t data[8]={};
|
|
in_network_callback=true;
|
|
server.upload(&request,"firmware",0,data,sizeof(data),true);
|
|
in_network_callback=false;
|
|
};
|
|
#if defined(TEST_OTA_SECOND_FILE) || defined(TEST_OTA_INCOMPLETE_POST)
|
|
{
|
|
Request rejected;
|
|
upload(rejected);
|
|
assert(ota_upload_owner==&rejected && ota_upload_busy.load()
|
|
&& !rejected.isSent() && !rejected.getResponse());
|
|
#ifdef TEST_OTA_SECOND_FILE
|
|
// AsyncWebServer starts each multipart file at index zero. A second file
|
|
// must keep its queued 409 instead of committing the already received file.
|
|
upload(rejected);
|
|
assert(!rejected.isSent() && rejected.getResponse() && rejected.code==409);
|
|
complete(rejected);
|
|
assert(rejected.code==409 && rejected.body=="OTA upload already active");
|
|
#else
|
|
// The file-final boundary can arrive without the closing POST boundary.
|
|
// Do not invoke the completion callback for this interrupted request.
|
|
assert(rejected.code==0);
|
|
#endif
|
|
in_network_callback=true; rejected.disconnected(); in_network_callback=false;
|
|
// Execute any incorrectly scheduled restart so the negative control checks
|
|
// both scheduling and the boot effect, rather than leaving work pending.
|
|
if (pending_task) { auto task=pending_task; pending_task=nullptr; task(nullptr); }
|
|
assert(Update.ends==0 && Update.boot_commits==0 && Update.md5_checks==0
|
|
&& Update.aborts==1 && !Update.running && !ota_upload_owner
|
|
&& !ota_upload_busy.load() && !ota_reboot_pending.load()
|
|
&& !pending_task && ESP.restarts==0);
|
|
// Cleanup permits a valid upload on the same listener without restarting it.
|
|
Request valid;
|
|
upload(valid);
|
|
assert(Update.ends==0 && Update.boot_commits==0 && Update.running);
|
|
complete(valid);
|
|
assert(valid.code==200 && valid.body=="OK" && Update.ends==1
|
|
&& Update.boot_commits==1 && Update.md5_checks==1 && !Update.running
|
|
&& ota_upload_owner==&valid && !pending_task && ESP.restarts==0);
|
|
in_network_callback=true; valid.disconnected(); in_network_callback=false;
|
|
assert(!ota_upload_owner && !ota_upload_busy.load() && pending_task && ESP.restarts==0);
|
|
auto task=pending_task; pending_task=nullptr; task(nullptr);
|
|
assert(ESP.restarts==1);
|
|
return 0;
|
|
}
|
|
#endif
|
|
#ifdef TEST_OTA_BODY_TIMEOUT
|
|
{
|
|
uint8_t chunk[4]={};
|
|
auto receive=[&](Request& request,size_t index,bool final) {
|
|
assert(!request.connection.closed);
|
|
request.connection.received();
|
|
in_network_callback=true;
|
|
server.upload(&request,"firmware",index,chunk,sizeof(chunk),final);
|
|
in_network_callback=false;
|
|
};
|
|
auto idle=[&](Request& request,unsigned ms) {
|
|
fixture_now+=ms;
|
|
in_network_callback=true; request.connection.poll(); in_network_callback=false;
|
|
};
|
|
Request ordinary;
|
|
assert(ordinary.connection._rx_timeout==3);
|
|
idle(ordinary,4000);
|
|
assert(ordinary.connection.closed && !ota_upload_owner);
|
|
Request admitted;
|
|
receive(admitted,0,false);
|
|
assert(ota_upload_owner==&admitted && !admitted.sent
|
|
&& admitted.connection.timeout_changes==std::vector<unsigned>({3,30}));
|
|
Request concurrent;
|
|
upload(concurrent);
|
|
assert(!concurrent.isSent() && concurrent.getResponse()
|
|
&& concurrent.connection.timeout_changes==std::vector<unsigned>({3}));
|
|
complete(concurrent);
|
|
assert(concurrent.code==409 && ota_upload_owner==&admitted
|
|
&& concurrent.connection.timeout_changes==std::vector<unsigned>({3,0}));
|
|
Request untouched;
|
|
assert(untouched.connection._rx_timeout==3);
|
|
idle(admitted,4000);
|
|
assert(!admitted.connection.closed && ota_upload_owner==&admitted);
|
|
idle(admitted,25999);
|
|
assert(!admitted.connection.closed);
|
|
idle(admitted,1);
|
|
assert(admitted.connection.closed && Update.aborts==1 && !ota_upload_owner
|
|
&& !ota_upload_busy.load() && !pending_task && ESP.restarts==0);
|
|
Request missing_md5;
|
|
missing_md5.md5_present=false;
|
|
upload(missing_md5);
|
|
assert(!missing_md5.isSent() && missing_md5.getResponse()
|
|
&& missing_md5.connection.timeout_changes==std::vector<unsigned>({3}));
|
|
complete(missing_md5);
|
|
assert(missing_md5.code==400
|
|
&& missing_md5.connection.timeout_changes==std::vector<unsigned>({3,0})
|
|
&& !ota_upload_owner && !ota_upload_busy.load());
|
|
Request bad_md5;
|
|
bad_md5.md5.text="invalid";
|
|
const int aborts_before_bad_md5=Update.aborts;
|
|
upload(bad_md5); complete(bad_md5);
|
|
assert(bad_md5.code==400 && !ota_upload_owner && !ota_upload_busy.load()
|
|
&& Update.aborts==aborts_before_bad_md5+1 && !Update.running
|
|
&& bad_md5.connection.timeout_changes==std::vector<unsigned>({3,0}));
|
|
ota.begin(&server,"admin","password");
|
|
Request unauthorized;
|
|
unauthorized.authorized=false;
|
|
upload(unauthorized); complete(unauthorized);
|
|
assert(unauthorized.code==401 && !ota_upload_owner && !ota_upload_busy.load()
|
|
&& unauthorized.connection.timeout_changes==std::vector<unsigned>({3,0}));
|
|
ota.begin(&server,"","");
|
|
Request successful;
|
|
receive(successful,0,false);
|
|
idle(successful,4000);
|
|
assert(!successful.connection.closed && ota_upload_owner==&successful);
|
|
receive(successful,sizeof(chunk),true);
|
|
complete(successful);
|
|
assert(successful.code==200 && successful.body=="OK"
|
|
&& successful.connection._rx_timeout==0 && !pending_task
|
|
&& Update.md5_checks==1 && !Update.running);
|
|
in_network_callback=true; successful.disconnected(); in_network_callback=false;
|
|
assert(pending_task && !ota_upload_owner);
|
|
auto task=pending_task; pending_task=nullptr; task(nullptr);
|
|
assert(ESP.restarts==1);
|
|
return 0;
|
|
}
|
|
#endif
|
|
// A POST without an actual completed upload cannot report OK or reboot.
|
|
Request empty;
|
|
complete(empty);
|
|
assert(empty.code>=400 && !pending_task && ESP.restarts==0);
|
|
Request invalid;
|
|
invalid.md5_present=false;
|
|
upload(invalid); complete(invalid);
|
|
assert(invalid.code==400 && !pending_task && ESP.restarts==0);
|
|
Request begin_failed;
|
|
Update.fail_begin=true;
|
|
upload(begin_failed); complete(begin_failed);
|
|
assert(begin_failed.code==400 && begin_failed.body=="OTA could not begin"
|
|
&& !ota_upload_owner && !ota_upload_busy.load() && Update.aborts==0
|
|
&& !pending_task && ESP.restarts==0);
|
|
Update.fail_begin=false;
|
|
// An invalid MD5 must release initialization without creating an owner,
|
|
// writing bytes, extending the receive timeout, or scheduling a reboot.
|
|
Request bad_md5;
|
|
bad_md5.md5.text="invalid";
|
|
const int writes_before_bad_md5=Update.writes;
|
|
const int begins_before_bad_md5=Update.begins;
|
|
upload(bad_md5); complete(bad_md5);
|
|
assert(bad_md5.code==400 && bad_md5.body=="MD5 parameter invalid"
|
|
&& !bad_md5._tempObject && !bad_md5.disconnected && !ota_upload_owner
|
|
&& !ota_upload_busy.load() && !Update.running && Update.aborts==1
|
|
&& Update.writes==writes_before_bad_md5 && Update.begins==begins_before_bad_md5+1
|
|
&& bad_md5.connection.timeout_changes==std::vector<unsigned>({3,0})
|
|
&& !pending_task && ESP.restarts==0);
|
|
// A correctly sized but incorrect digest is admitted, then rejected only
|
|
// after all file bytes arrive. Its owner stays reserved until disconnect.
|
|
Request mismatched_md5;
|
|
mismatched_md5.md5.text=fixture_corrupt_md5;
|
|
upload(mismatched_md5); complete(mismatched_md5);
|
|
assert(mismatched_md5.code==400 && mismatched_md5.body=="Could not end OTA"
|
|
&& ota_upload_owner==&mismatched_md5 && ota_upload_busy.load()
|
|
&& !static_cast<OtaUploadState*>(mismatched_md5._tempObject)->committed
|
|
&& Update.md5_checks==1 && !pending_task && ESP.restarts==0);
|
|
in_network_callback=true; mismatched_md5.disconnected(); in_network_callback=false;
|
|
assert(Update.aborts==2 && !ota_upload_owner && !ota_upload_busy.load()
|
|
&& !Update.running && !pending_task && ESP.restarts==0);
|
|
// Corrupt the actual bytes with the original digest still supplied. This
|
|
// independently rejects corruption instead of relying on a forced end error.
|
|
Request corrupt_bytes;
|
|
uint8_t corrupted_data[8]={1};
|
|
in_network_callback=true;
|
|
server.upload(&corrupt_bytes,"firmware",0,corrupted_data,sizeof(corrupted_data),true);
|
|
in_network_callback=false;
|
|
complete(corrupt_bytes);
|
|
assert(corrupt_bytes.code==400 && corrupt_bytes.body=="Could not end OTA"
|
|
&& ota_upload_owner==&corrupt_bytes && ota_upload_busy.load()
|
|
&& !static_cast<OtaUploadState*>(corrupt_bytes._tempObject)->committed
|
|
&& Update.md5_checks==2 && !pending_task && ESP.restarts==0);
|
|
in_network_callback=true; corrupt_bytes.disconnected(); in_network_callback=false;
|
|
assert(Update.aborts==3 && !ota_upload_owner && !ota_upload_busy.load()
|
|
&& !Update.running && !pending_task && ESP.restarts==0);
|
|
Request corrupt;
|
|
Update.fail_end=true;
|
|
upload(corrupt); complete(corrupt);
|
|
assert(corrupt.code==400 && !pending_task && ESP.restarts==0);
|
|
in_network_callback=true; corrupt.disconnected(); in_network_callback=false;
|
|
assert(Update.aborts==4 && ota_upload_owner==nullptr);
|
|
Update.fail_end=false;
|
|
Request partial;
|
|
uint8_t chunk[4]={};
|
|
server.upload(&partial,"firmware",0,chunk,sizeof(chunk),false);
|
|
assert(!ota.setEnabled(false)); // An admitted upload keeps its listener alive.
|
|
Request concurrent;
|
|
upload(concurrent); complete(concurrent);
|
|
assert(concurrent.code==409 && ota_upload_owner==&partial);
|
|
in_network_callback=true; partial.disconnected(); in_network_callback=false;
|
|
assert(Update.aborts==5 && ota_upload_owner==nullptr && ESP.restarts==0);
|
|
assert(ota.setEnabled(false));
|
|
Request stopped;
|
|
upload(stopped); complete(stopped);
|
|
assert(stopped.code==503 && !ota_upload_owner && !pending_task);
|
|
assert(ota.setEnabled(true));
|
|
Request write_failed;
|
|
Update.fail_write=true;
|
|
server.upload(&write_failed,"firmware",0,chunk,sizeof(chunk),false);
|
|
assert(!write_failed.isSent() && write_failed.getResponse()
|
|
&& write_failed.code==400 && write_failed.body=="OTA flash write failed");
|
|
const int writes_after_error=Update.writes;
|
|
server.upload(&write_failed,"firmware",sizeof(chunk),chunk,sizeof(chunk),true);
|
|
assert(Update.writes==writes_after_error);
|
|
server.upload(&write_failed,"firmware",0,chunk,sizeof(chunk),false);
|
|
assert(Update.writes==writes_after_error && write_failed.code==400
|
|
&& write_failed.body=="OTA flash write failed");
|
|
complete(write_failed);
|
|
assert(write_failed.code==400 && write_failed.body=="OTA flash write failed"
|
|
&& !pending_task);
|
|
in_network_callback=true; write_failed.disconnected(); in_network_callback=false;
|
|
assert(Update.aborts==6 && !ota_upload_owner && !ota_upload_busy.load());
|
|
Update.fail_write=false;
|
|
Request valid;
|
|
const int ends_before_valid=Update.ends, commits_before_valid=Update.boot_commits;
|
|
upload(valid);
|
|
assert(Update.ends==ends_before_valid && Update.boot_commits==commits_before_valid
|
|
&& Update.running && !static_cast<OtaUploadState*>(valid._tempObject)->committed);
|
|
complete(valid);
|
|
assert(valid.code==200 && valid.disconnected && !pending_task && ESP.restarts==0
|
|
&& Update.md5_checks==3 && !Update.running
|
|
&& Update.ends==ends_before_valid+1 && Update.boot_commits==commits_before_valid+1);
|
|
// The response drains before disconnect; its callback only schedules work.
|
|
in_network_callback=true; valid.disconnected(); in_network_callback=false;
|
|
assert(pending_task && ESP.restarts==0);
|
|
auto task=pending_task; pending_task=nullptr; task(nullptr);
|
|
assert(ESP.restarts==1);
|
|
}
|