diff --git a/README.md b/README.md index 16bfd7db..4c14740c 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,20 @@ If you have any difficulties, help is available on [Discord](https://discord.gg/ 2. Update `config.sys` file to use a `usbcd1.sys` file. The developers recommend the panasonic one (`Panasonic USB CD-ROM Driver v1.0`). 3. Update `autoexec` to use `mscdex.exe` or `SHSUCDX.exe` with the switch `/d:usbcd001` since that is the default CDROM device name provided by usbcd1.sys. +### Configuring WiFi from the connected computer + +If the Pi is already plugged in, WiFi can be set up without taking the MicroSD card out. Edit a `wpa_supplicant.conf` on the connected computer using the same format described in Initial Setup, then send it with the SCSI Toolbox client: + +``` +scsitb put wpa_supplicant.conf +``` + +`WIFI.CFG` is also accepted, for systems limited to 8.3 filenames. Either name replaces USBODE's `wpa_supplicant.conf`; the contents are used exactly as if you had edited the file on the card. The upload is limited to 8 KiB, which is far more than a WiFi configuration needs. + +Once the transfer completes, USBODE installs the file and reboots by itself to pick up the new settings. An upload that fails or is interrupted never reboots, and USBODE preserves your previous configuration: normally it stays exactly where it was, and in the rare case where it cannot be put back under its own name it is kept alongside as `wpa_supplicant.bak`. + +Only those two destination names are accepted. This is not a general file upload, and no other file on the card can be written this way. + ## Using the USBODE Web Interface The browser interface is used to load images, shutdown/reboot the device, configure settings, and view logs.To access the interface, you’ll need the IP address of the Pi. Once it connects to your WiFi, this address can be viewed from your router’s configuration page. It should appear as “usbode” in the list of connected devices. If you use a display HAT, the display will also show the IP address. Use that IP address preceded by “http://” (not “https://”). For example, if your Pi’s IP address is 192.168.0.4, you would enter `http://192.168.0.4` into your browser’s address bar. The address http://usbode or http://usbode.local should also work as well. diff --git a/addon/configservice/Makefile b/addon/configservice/Makefile index 549d5722..f21d50f3 100644 --- a/addon/configservice/Makefile +++ b/addon/configservice/Makefile @@ -9,7 +9,8 @@ CIRCLEHOME = $(STDLIBHOME)/libs/circle OBJS = configservice.o \ cmdline.o \ - config.o + config.o \ + wificonfig.o libconfigservice.a: $(OBJS) @echo " AR $@" diff --git a/addon/configservice/configservice.cpp b/addon/configservice/configservice.cpp index c8955fd5..8f4b34bf 100644 --- a/addon/configservice/configservice.cpp +++ b/addon/configservice/configservice.cpp @@ -1,10 +1,12 @@ #include "configservice.h" #include "config.h" #include "cmdline.h" +#include "wificonfig.h" #include #include #include +#include #include "simpleini.hpp" LOGMODULE("configservice"); @@ -335,6 +337,17 @@ void ConfigService::Run(void) { LOGNOTE("Saved configuration"); } + // Same reason: the SCSI Toolbox stages an uploaded + // wpa_supplicant.conf from an interrupt, we install it here. + CWiFiConfigUpload &wifi = CWiFiConfigUpload::Get(); + if (wifi.CommitPending()) { + wifi.ProcessCommit(); + if (wifi.ConsumeRebootRequest()) { + LOGNOTE("Wi-Fi configuration installed, rebooting"); + new CShutdown(ShutdownReboot, 3000); + } + } + CScheduler::Get()->MsSleep(100); } diff --git a/addon/configservice/wificonfig.cpp b/addon/configservice/wificonfig.cpp new file mode 100644 index 00000000..0ec8e246 --- /dev/null +++ b/addon/configservice/wificonfig.cpp @@ -0,0 +1,319 @@ +// +// wificonfig.cpp +// +// Copyright (C) 2025 USBODE contributors +// +// 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 . +// +#include + +#include +#include +#include + +LOGMODULE("wificonfig"); + +#define WIFI_CONFIG_PATH "0:/wpa_supplicant.conf" +#define WIFI_CONFIG_TEMP "0:/wpa_supplicant.tmp" +#define WIFI_CONFIG_BACKUP "0:/wpa_supplicant.bak" + +// File scope rather than function-local: Get() is reached from IRQ context, +// where taking a static initialization guard is not something to rely on. +static CWiFiConfigUpload s_WiFiConfigUpload; + +CWiFiConfigUpload &CWiFiConfigUpload::Get(void) +{ + return s_WiFiConfigUpload; +} + +// A plain memset over a buffer nothing reads again is dead-store eliminated, +// which would leave the password in RAM. Writing through volatile cannot be. +static void SecureZero(void *pBuffer, size_t nSize) +{ + volatile u8 *p = (volatile u8 *)pBuffer; + while (nSize-- > 0) + { + *p++ = 0; + } +} + +// This is a Wi-Fi configuration upload, not a general file transfer. Only the +// two names USBODE documents for that file are accepted. +static const char *const s_AcceptedNames[] = {"WIFI.CFG", "wpa_supplicant.conf"}; + +static char ToLower(char c) +{ + return (c >= 'A' && c <= 'Z') ? (char)(c - 'A' + 'a') : c; +} + +static bool EqualsIgnoreCase(const char *pA, const char *pB) +{ + while (*pA != '\0' && *pB != '\0') + { + if (ToLower(*pA++) != ToLower(*pB++)) + { + return false; + } + } + return *pA == '\0' && *pB == '\0'; +} + +bool CWiFiConfigUpload::Begin(const u8 *pName, size_t nNameLength) +{ + if (IsBusy() || pName == nullptr) + { + return false; + } + + // Staging dies on a second PREP even if this one is about to be rejected. + Abort(); + + if (nNameLength > NameFieldSize) + { + nNameLength = NameFieldSize; + } + + size_t nLength = 0; + while (nLength < nNameLength && pName[nLength] != '\0') + { + nLength++; + } + if (nLength == 0 || nLength == nNameLength) + { + return false; // empty, or no terminator inside the parameter list + } + + for (size_t i = 0; i < nLength; i++) + { + u8 c = pName[i]; + if (c < 0x20 || c == 0x7F) + { + return false; // embedded control characters + } + if (c == '/' || c == '\\' || c == ':') + { + return false; // path separators and drive prefixes + } + } + if (nLength >= 2 && pName[0] == '.' && pName[1] == '.') + { + return false; // dot traversal + } + + char name[NameFieldSize]; + memcpy(name, pName, nLength); + name[nLength] = '\0'; + + bool bAccepted = false; + for (size_t i = 0; i < sizeof(s_AcceptedNames) / sizeof(s_AcceptedNames[0]); i++) + { + if (EqualsIgnoreCase(name, s_AcceptedNames[i])) + { + bAccepted = true; + break; + } + } + if (!bAccepted) + { + return false; + } + + memset(m_Buffer, 0, sizeof(m_Buffer)); + m_nLength = 0; + memcpy(m_Name, name, nLength + 1); + m_State = StateReceiving; + + LOGNOTE("Wi-Fi configuration upload started (%s)", m_Name); + return true; +} + +bool CWiFiConfigUpload::Stage(u32 nBlockIndex, const u8 *pData, u32 nLength) +{ + if (m_State != StateReceiving || pData == nullptr) + { + return false; + } + if (nLength == 0 || nLength > BlockSize) + { + return false; + } + if (nBlockIndex > (MaxConfigSize - 1) / BlockSize) + { + return false; + } + + // nBlockIndex is bounded above, so the product fits; the subtraction cannot + // wrap because nLength is at most BlockSize. + u32 nOffset = nBlockIndex * BlockSize; + if (nOffset > MaxConfigSize - nLength) + { + return false; + } + + // Refusing a gap keeps the staged file contiguous. Retries land at or + // inside what is already there, so they still pass. + if (nOffset > m_nLength) + { + return false; + } + + memcpy(m_Buffer + nOffset, pData, nLength); + if (nOffset + nLength > m_nLength) + { + m_nLength = nOffset + nLength; + } + return true; +} + +bool CWiFiConfigUpload::RequestCommit(void) +{ + if (m_State != StateReceiving || m_nLength == 0) + { + return false; + } + m_State = StateCommitQueued; + return true; +} + +void CWiFiConfigUpload::Abort(void) +{ + if (IsBusy()) + { + return; + } + Wipe(); +} + +void CWiFiConfigUpload::Wipe(void) +{ + SecureZero(m_Buffer, sizeof(m_Buffer)); + SecureZero(m_Name, sizeof(m_Name)); + m_nLength = 0; + m_State = StateIdle; +} + +void CWiFiConfigUpload::ProcessCommit(void) +{ + if (m_State != StateCommitQueued) + { + return; + } + + // Claimed before the first FatFs call, which is the first place this can + // yield, so the IRQ side cannot wipe the buffer from under the write. + m_State = StateCommitting; + + u32 nLength = m_nLength; + if (WriteTempFile() && InstallTempFile()) + { + LOGNOTE("Wi-Fi configuration replaced (%u bytes)", (unsigned)nLength); + m_bRebootRequested = true; + } + else + { + LOGERR("Wi-Fi configuration upload failed, no new configuration installed"); + } + + Wipe(); +} + +bool CWiFiConfigUpload::ConsumeRebootRequest(void) +{ + if (!m_bRebootRequested) + { + return false; + } + m_bRebootRequested = false; + return true; +} + +bool CWiFiConfigUpload::WriteTempFile(void) +{ + f_unlink(WIFI_CONFIG_TEMP); + + FIL File; + if (f_open(&File, WIFI_CONFIG_TEMP, FA_WRITE | FA_CREATE_ALWAYS) != FR_OK) + { + return false; + } + + UINT nWritten = 0; + FRESULT Result = f_write(&File, m_Buffer, (UINT)m_nLength, &nWritten); + + // A full card is FR_OK with a short count, so the byte count decides, not + // the result code. + if (Result != FR_OK || nWritten != m_nLength || f_sync(&File) != FR_OK) + { + f_close(&File); + f_unlink(WIFI_CONFIG_TEMP); + return false; + } + + if (f_close(&File) != FR_OK) + { + f_unlink(WIFI_CONFIG_TEMP); + return false; + } + return true; +} + +static bool FileExists(const char *pPath) +{ + FIL File; + if (f_open(&File, pPath, FA_READ) != FR_OK) + { + return false; + } + f_close(&File); + return true; +} + +bool CWiFiConfigUpload::InstallTempFile(void) +{ + // A backup with no working file beside it is a configuration rescued from + // a failed rollback, so stale backups are only cleared when one exists. + bool bHaveWorking = FileExists(WIFI_CONFIG_PATH); + if (bHaveWorking) + { + f_unlink(WIFI_CONFIG_BACKUP); + } + + // FAT has no atomic replace, so the working file is moved aside first and + // moved back if the new one cannot take its place. + FRESULT Moved = FR_NO_FILE; + if (bHaveWorking) + { + Moved = f_rename(WIFI_CONFIG_PATH, WIFI_CONFIG_BACKUP); + if (Moved != FR_OK) + { + f_unlink(WIFI_CONFIG_TEMP); + return false; + } + } + + if (f_rename(WIFI_CONFIG_TEMP, WIFI_CONFIG_PATH) != FR_OK) + { + // If the rollback fails too the old configuration still exists, just + // under the backup name, so say where it is rather than claim success. + if (Moved == FR_OK && f_rename(WIFI_CONFIG_BACKUP, WIFI_CONFIG_PATH) != FR_OK) + { + LOGERR("Could not restore " WIFI_CONFIG_PATH ", it is now " WIFI_CONFIG_BACKUP); + } + f_unlink(WIFI_CONFIG_TEMP); + return false; + } + + f_unlink(WIFI_CONFIG_BACKUP); + return true; +} diff --git a/addon/configservice/wificonfig.h b/addon/configservice/wificonfig.h new file mode 100644 index 00000000..dcde8d7a --- /dev/null +++ b/addon/configservice/wificonfig.h @@ -0,0 +1,102 @@ +// +// wificonfig.h +// +// Staging and commit for a Wi-Fi configuration uploaded over the SCSI +// Toolbox send-file commands (escsitoolbox `put`). +// +// Copyright (C) 2025 USBODE contributors +// +// 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 . +// +#ifndef _configservice_wificonfig_h +#define _configservice_wificonfig_h + +#include + +// Holds an uploaded wpa_supplicant.conf in RAM until a task context can write +// it. Only ProcessCommit() touches FatFs; everything else runs in IRQ context. +class CWiFiConfigUpload +{ +public: + // A WPA configuration with a handful of networks is a few hundred bytes. + // 8 KiB is the documented cap and bounds the IRQ-side staging buffer. + static const u32 MaxConfigSize = 8192; + + // Fixed by the protocol, not a buffer choice: CDB block indices scale by it. + static const u32 BlockSize = 512; + + // 32 filename characters plus the terminator, matching the 33-byte + // parameter list TOOLBOX_SEND_FILE_PREP carries. + static const u32 NameFieldSize = 33; + + static CWiFiConfigUpload &Get(void); + + // --- IRQ context: the SCSI data-out path --- + + // pName is the raw parameter list: it must carry its own NUL and name a + // file this upload is allowed to replace. + bool Begin(const u8 *pName, size_t nNameLength); + + // A repeated block index overwrites in place rather than appending. + bool Stage(u32 nBlockIndex, const u8 *pData, u32 nLength); + + // Fails when nothing is staged, so an empty upload cannot truncate the file. + bool RequestCommit(void); + + // Discard a half-received upload. A queued or running commit is left + // alone: it no longer depends on the host. + void Abort(void); + + bool IsReceiving(void) const { return m_State == StateReceiving; } + bool IsBusy(void) const + { + return m_State == StateCommitQueued || m_State == StateCommitting; + } + + u32 StagedLength(void) const { return m_nLength; } + const char *StagedName(void) const { return m_Name; } + + // --- Task context: ConfigService::Run() --- + + bool CommitPending(void) const { return m_State == StateCommitQueued; } + + // Leaves the existing configuration untouched if any step fails, and + // clears the staged bytes either way. + void ProcessCommit(void); + + // True once after a commit succeeded, so the caller schedules exactly one + // reboot. + bool ConsumeRebootRequest(void); + +private: + enum TState + { + StateIdle, + StateReceiving, + StateCommitQueued, + StateCommitting + }; + + void Wipe(void); + bool WriteTempFile(void); + bool InstallTempFile(void); + + TState m_State = StateIdle; + u32 m_nLength = 0; + bool m_bRebootRequested = false; + char m_Name[NameFieldSize] = {0}; + u8 m_Buffer[MaxConfigSize] = {0}; +}; + +#endif diff --git a/addon/usbcdgadget/scsi_toolbox.cpp b/addon/usbcdgadget/scsi_toolbox.cpp index 14ba7899..582dd390 100644 --- a/addon/usbcdgadget/scsi_toolbox.cpp +++ b/addon/usbcdgadget/scsi_toolbox.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -127,3 +128,212 @@ void SCSIToolbox::SetNextCD(CUSBCDGadget* gadget) gadget->m_CSW.bmCSWStatus = CD_CSW_STATUS_OK; gadget->SendCSW(); } + +// TOOLBOX_SEND_FILE_PREP carries a 33-byte parameter list: up to 32 filename +// characters and the NUL the client is required to include. +static const u32 SendFilePrepLength = 33; + +// TOOLBOX_SEND_FILE_END is documented as taking no data, but the DOS client +// declares four bytes of it. Those two lengths are the only ones accepted. +static const u32 SendFileEndLength = 4; + +// Sized from what the host declares over BOT, not from the CDB's valid count: +// the client moves a whole block whatever that count says. +void SCSIToolbox::BeginSendFileDataOut(CUSBCDGadget *gadget, u32 nLength) +{ + gadget->m_CSW.bmCSWStatus = CD_CSW_STATUS_OK; + gadget->m_nState = CUSBCDGadget::TCDState::DataOut; + gadget->m_pEP[CUSBCDGadget::EPOut]->BeginTransfer(CUSBCDGadgetEndpoint::TransferDataOut, + gadget->m_OutBuffer, nLength); +} + +// Sets sense on failure but never sends the CSW: the two D5 forms send it +// from different places. +bool SCSIToolbox::FinishSendFile(CUSBCDGadget *gadget) +{ + if (!CWiFiConfigUpload::Get().RequestCommit()) + { + MLOGERR("SCSIToolbox::SendFileEnd", "No staged data to commit"); + CWiFiConfigUpload::Get().Abort(); + gadget->setSenseData(0x05, 0x2c, 0x00); // COMMAND SEQUENCE ERROR + return false; + } + + MLOGNOTE("SCSIToolbox::SendFileEnd", "Wi-Fi configuration queued for commit"); + return true; +} + +void SCSIToolbox::SendFilePrep(CUSBCDGadget *gadget) +{ + gadget->m_nnumber_blocks = 0; // never resume a pending read behind this + + if (CWiFiConfigUpload::Get().IsBusy()) + { + MLOGERR("SCSIToolbox::SendFilePrep", "A commit is already in flight"); + gadget->setSenseData(0x02, 0x04, 0x01); // LU IN PROCESS OF BECOMING READY + gadget->sendCheckCondition(); + return; + } + + // A second PREP abandons whatever the first one staged, whether or not this + // one turns out to name an acceptable destination. + CWiFiConfigUpload::Get().Abort(); + + u32 nLength = gadget->m_CBW.dCBWDataTransferLength; + if ((gadget->m_CBW.bmCBWFlags & 0x80) || nLength == 0 || nLength > SendFilePrepLength) + { + MLOGERR("SCSIToolbox::SendFilePrep", "Bad parameter list length %u", nLength); + gadget->setSenseData(0x05, 0x1a, 0x00); // PARAMETER LIST LENGTH ERROR + gadget->sendCheckCondition(); + return; + } + + BeginSendFileDataOut(gadget, nLength); +} + +void SCSIToolbox::SendFile10(CUSBCDGadget *gadget) +{ + gadget->m_nnumber_blocks = 0; + + if (!CWiFiConfigUpload::Get().IsReceiving()) + { + MLOGERR("SCSIToolbox::SendFile10", "No upload in progress"); + gadget->setSenseData(0x05, 0x2c, 0x00); // COMMAND SEQUENCE ERROR + gadget->sendCheckCondition(); + return; + } + + u32 nValidLength = ((u32)gadget->m_CBW.CBWCB[1] << 8) | gadget->m_CBW.CBWCB[2]; + u32 nBlockIndex = ((u32)gadget->m_CBW.CBWCB[3] << 16) | + ((u32)gadget->m_CBW.CBWCB[4] << 8) | gadget->m_CBW.CBWCB[5]; + + if (nValidLength == 0 || nValidLength > CWiFiConfigUpload::BlockSize || + nBlockIndex > (CWiFiConfigUpload::MaxConfigSize - 1) / CWiFiConfigUpload::BlockSize) + { + MLOGERR("SCSIToolbox::SendFile10", "Block %u length %u out of range", + nBlockIndex, nValidLength); + CWiFiConfigUpload::Get().Abort(); + gadget->setSenseData(0x05, 0x24, 0x00); // INVALID FIELD IN CDB + gadget->sendCheckCondition(); + return; + } + + u32 nLength = gadget->m_CBW.dCBWDataTransferLength; + if ((gadget->m_CBW.bmCBWFlags & 0x80) || nLength < nValidLength || + nLength > CUSBCDGadget::MaxOutMessageSize) + { + MLOGERR("SCSIToolbox::SendFile10", "Declared transfer length %u unusable", nLength); + CWiFiConfigUpload::Get().Abort(); + gadget->setSenseData(0x05, 0x1a, 0x00); + gadget->sendCheckCondition(); + return; + } + + BeginSendFileDataOut(gadget, nLength); +} + +void SCSIToolbox::SendFileEnd(CUSBCDGadget *gadget) +{ + gadget->m_nnumber_blocks = 0; + + if (!CWiFiConfigUpload::Get().IsReceiving()) + { + MLOGERR("SCSIToolbox::SendFileEnd", "No upload in progress"); + gadget->setSenseData(0x05, 0x2c, 0x00); + gadget->sendCheckCondition(); + return; + } + + // BOT 6.2: the direction bit means nothing when no data is declared, which + // is exactly the documented no-payload form of this command. + u32 nLength = gadget->m_CBW.dCBWDataTransferLength; + if ((nLength != 0 && nLength != SendFileEndLength) || + (nLength > 0 && (gadget->m_CBW.bmCBWFlags & 0x80))) + { + MLOGERR("SCSIToolbox::SendFileEnd", "Declared transfer length %u unusable", nLength); + CWiFiConfigUpload::Get().Abort(); + gadget->setSenseData(0x05, 0x1a, 0x00); + gadget->sendCheckCondition(); + return; + } + + // The four bytes the DOS client sends carry nothing, but they still have to + // be drained before the commit so the data phase completes. + if (nLength > 0) + { + BeginSendFileDataOut(gadget, nLength); + return; + } + + if (FinishSendFile(gadget)) + { + gadget->sendGoodStatus(); + } + else + { + gadget->sendCheckCondition(); + } +} + +void SCSIToolbox::ProcessSendFileOut(CUSBCDGadget *gadget, size_t nLength) +{ + CWiFiConfigUpload &upload = CWiFiConfigUpload::Get(); + + switch (gadget->m_CBW.CBWCB[0]) + { + case 0xD3: + { + if (!upload.Begin(gadget->m_OutBuffer, nLength)) + { + // The rejected name is not logged: it is host-supplied bytes. + MLOGERR("SCSIToolbox::SendFilePrep", "Destination refused (%u bytes)", + (unsigned)nLength); + gadget->setSenseData(0x05, 0x26, 0x00); // INVALID FIELD IN PARAMETER LIST + gadget->m_CSW.bmCSWStatus = CD_CSW_STATUS_FAIL; + } + break; + } + + case 0xD4: + { + u32 nValidLength = ((u32)gadget->m_CBW.CBWCB[1] << 8) | gadget->m_CBW.CBWCB[2]; + u32 nBlockIndex = ((u32)gadget->m_CBW.CBWCB[3] << 16) | + ((u32)gadget->m_CBW.CBWCB[4] << 8) | gadget->m_CBW.CBWCB[5]; + + // Only the declared bytes are real; the rest of the 512-byte transfer + // is whatever the client happened to have in its buffer. + if (nValidLength > nLength) + { + MLOGERR("SCSIToolbox::SendFile10", "Short data phase: %u of %u bytes", + (unsigned)nLength, nValidLength); + upload.Abort(); + gadget->setSenseData(0x05, 0x1a, 0x00); + gadget->m_CSW.bmCSWStatus = CD_CSW_STATUS_FAIL; + break; + } + + if (!upload.Stage(nBlockIndex, gadget->m_OutBuffer, nValidLength)) + { + MLOGERR("SCSIToolbox::SendFile10", "Block %u rejected", nBlockIndex); + upload.Abort(); + gadget->setSenseData(0x05, 0x24, 0x00); + gadget->m_CSW.bmCSWStatus = CD_CSW_STATUS_FAIL; + } + break; + } + + case 0xD5: + { + if (!FinishSendFile(gadget)) + { + gadget->m_CSW.bmCSWStatus = CD_CSW_STATUS_FAIL; + } + break; + } + } +} + +void SCSIToolbox::ResetSendFileState(void) +{ + CWiFiConfigUpload::Get().Abort(); +} diff --git a/addon/usbcdgadget/scsi_toolbox.h b/addon/usbcdgadget/scsi_toolbox.h index e560fbb7..6e1394e9 100644 --- a/addon/usbcdgadget/scsi_toolbox.h +++ b/addon/usbcdgadget/scsi_toolbox.h @@ -15,6 +15,26 @@ class SCSIToolbox static void NumberOfFiles(CUSBCDGadget* gadget); static void ListFiles(CUSBCDGadget* gadget); static void SetNextCD(CUSBCDGadget* gadget); + + // escsitoolbox `put`: 0xD3 names the destination, 0xD4 carries 512-byte + // blocks, 0xD5 closes. Restricted here to the Wi-Fi configuration file. + static void SendFilePrep(CUSBCDGadget* gadget); + static void SendFile10(CUSBCDGadget* gadget); + static void SendFileEnd(CUSBCDGadget* gadget); + + // Data-out completion for the three commands above, routed by opcode so + // that no upload payload reaches the MODE SELECT parser. + static void ProcessSendFileOut(CUSBCDGadget* gadget, size_t nLength); + + // Drop a half-received upload, e.g. after a USB reset. A queued commit is + // left alone: it no longer depends on the host. + static void ResetSendFileState(void); + +private: + // Members rather than file statics because they reach into the gadget's + // endpoints and sense data, and only this class is a friend of it. + static void BeginSendFileDataOut(CUSBCDGadget* gadget, u32 nLength); + static bool FinishSendFile(CUSBCDGadget* gadget); }; #endif diff --git a/addon/usbcdgadget/usbcdgadget.cpp b/addon/usbcdgadget/usbcdgadget.cpp index f391ad95..08738c91 100644 --- a/addon/usbcdgadget/usbcdgadget.cpp +++ b/addon/usbcdgadget/usbcdgadget.cpp @@ -330,6 +330,9 @@ void CUSBCDGadget::InitSCSIHandlers() m_SCSIHandlers[0xD0] = SCSIToolbox::ListFiles; m_SCSIHandlers[0xD7] = SCSIToolbox::ListFiles; // Same implementation m_SCSIHandlers[0xD8] = SCSIToolbox::SetNextCD; + m_SCSIHandlers[0xD3] = SCSIToolbox::SendFilePrep; + m_SCSIHandlers[0xD4] = SCSIToolbox::SendFile10; + m_SCSIHandlers[0xD5] = SCSIToolbox::SendFileEnd; // Misc m_SCSIHandlers[0x00] = SCSIMisc::TestUnitReady; @@ -899,6 +902,17 @@ void CUSBCDGadget::OnTransferComplete(boolean bIn, size_t nLength) void CUSBCDGadget::ProcessOut(size_t nLength) { + // Toolbox uploads own their data-out payload. Falling through would both + // misparse it as a mode page and hex-dump a Wi-Fi password below. + switch (m_CBW.CBWCB[0]) + { + case 0xD3: + case 0xD4: + case 0xD5: + SCSIToolbox::ProcessSendFileOut(this, nLength); + return; + } + // This code is assuming that the payload is a Mode Select payload. // At the moment, this is the only thing likely to appear here. // TODO: somehow validate what this data is @@ -988,6 +1002,9 @@ void CUSBCDGadget::OnActivate() IsEffectiveFullSpeed() ? "Full-Speed (USB 1.1)" : "High-Speed (USB 2.0)", m_CDReady, (int)m_mediaState); CTimer::Get()->MsDelay(10); + // A reset or re-enumeration ends any half-received toolbox upload; the + // host has to start over rather than resume into stale staging. + SCSIToolbox::ResetSendFileState(); // Set media ready NOW - USB endpoints are active. // Skip while ejected: the drive must stay empty across a re-enumeration // until the user (or host) explicitly re-inserts. diff --git a/integration-tests/Makefile b/integration-tests/Makefile index 7369e396..4c1fc336 100644 --- a/integration-tests/Makefile +++ b/integration-tests/Makefile @@ -72,7 +72,8 @@ DISCIMAGE_SRCS := \ # behaviour worth pinning: a bad path used to cost 20 ms of scheduler time per # log event, which presented as the whole Pi having gone slow. SERVICE_SRCS := \ - $(ADDON)/filelogdaemon/filelogdaemon.cpp + $(ADDON)/filelogdaemon/filelogdaemon.cpp \ + $(ADDON)/configservice/wificonfig.cpp CHDR_OBJS := ifneq ($(WITH_CHD),1) diff --git a/integration-tests/harness/fatfs_host.cpp b/integration-tests/harness/fatfs_host.cpp index f50b2a36..7f032aa6 100644 --- a/integration-tests/harness/fatfs_host.cpp +++ b/integration-tests/harness/fatfs_host.cpp @@ -13,6 +13,8 @@ #include +#include + namespace { constexpr size_t kNoWriteLimit = (size_t)-1; @@ -21,6 +23,28 @@ size_t s_WriteLimit = kNoWriteLimit; size_t s_BytesAccepted = 0; bool s_SyncFails = false; unsigned s_LinkmapCount = 0; +unsigned s_FailRenameAt = 0; +unsigned s_FailRenameCount = 0; +unsigned s_RenameCount = 0; +std::string s_DriveRoot; + +// "0:/wpa_supplicant.conf" -> "/wpa_supplicant.conf". Paths without a +// drive prefix, and every path at all while no root is set, pass through. +const char* MapPath(const char* path, std::string& storage) +{ + if (s_DriveRoot.empty() || path == nullptr) { + return path; + } + if (!(path[0] >= '0' && path[0] <= '9') || path[1] != ':') { + return path; + } + storage = s_DriveRoot; + if (path[2] != '/') { + storage += '/'; + } + storage += path + 2; + return storage.c_str(); +} } // namespace @@ -35,11 +59,26 @@ void FatFsHostFailSync(bool bFail) s_SyncFails = bFail; } +void FatFsHostFailRename(unsigned nFirst, unsigned nCount) +{ + s_FailRenameAt = nFirst; + s_FailRenameCount = nCount; + s_RenameCount = 0; +} + +void FatFsHostSetDriveRoot(const char* pPath) +{ + s_DriveRoot = (pPath != nullptr) ? pPath : ""; +} + void FatFsHostClearFaults(void) { s_WriteLimit = kNoWriteLimit; s_BytesAccepted = 0; s_SyncFails = false; + s_FailRenameAt = 0; + s_FailRenameCount = 0; + s_RenameCount = 0; } void FatFsHostResetLinkmapCount(void) @@ -60,6 +99,9 @@ FRESULT f_open(FIL* fp, const TCHAR* path, BYTE mode) return FR_INVALID_PARAMETER; } + std::string mapped; + path = MapPath(path, mapped); + // FA_OPEN_ALWAYS is "r+b" falling back to "w+b" only when the file is // missing, which keeps a bad directory an error. const char* stdioMode = "rb"; @@ -200,6 +242,47 @@ FRESULT f_lseek(FIL* fp, FSIZE_t ofs) return FR_OK; } +FRESULT f_unlink(const TCHAR* path) +{ + if (!path) { + return FR_INVALID_NAME; + } + std::string mapped; + return remove(MapPath(path, mapped)) == 0 ? FR_OK : FR_NO_FILE; +} + +FRESULT f_rename(const TCHAR* path_old, const TCHAR* path_new) +{ + if (!path_old || !path_new) { + return FR_INVALID_NAME; + } + ++s_RenameCount; + if (s_FailRenameAt != 0 && s_RenameCount >= s_FailRenameAt && + s_RenameCount < s_FailRenameAt + s_FailRenameCount) { + return FR_DENIED; + } + + std::string mappedOld; + std::string mappedNew; + const char* from = MapPath(path_old, mappedOld); + const char* to = MapPath(path_new, mappedNew); + + FILE* source = fopen(from, "rb"); + if (!source) { + return FR_NO_FILE; + } + fclose(source); + + // FatFs refuses to clobber an existing destination; rename(2) replaces it. + FILE* existing = fopen(to, "rb"); + if (existing) { + fclose(existing); + return FR_EXIST; + } + + return rename(from, to) == 0 ? FR_OK : FR_DENIED; +} + // Directory walk: intentionally unbacked. Only mdsfile.cpp calls these, and // MDS images are not exercised by the tests; these exist so the loader links. // f_opendir reports "no path" so any accidental MDS load fails cleanly rather diff --git a/integration-tests/harness/fatfs_host.h b/integration-tests/harness/fatfs_host.h index 2521e773..350308ee 100644 --- a/integration-tests/harness/fatfs_host.h +++ b/integration-tests/harness/fatfs_host.h @@ -16,6 +16,14 @@ void FatFsHostSetWriteLimit(size_t nBytes); // Make every f_sync() report FR_DISK_ERR. void FatFsHostFailSync(bool bFail); +// Fail nCount f_rename() calls starting at the nFirst-th from now (1-based; +// nFirst 0 disables). Which ones fail decides how far a rollback has to unwind. +void FatFsHostFailRename(unsigned nFirst, unsigned nCount = 1); + +// Point firmware paths that name a FatFs drive ("0:/x") at a host directory. +// nullptr or "" restores the pass-through default. +void FatFsHostSetDriveRoot(const char *pPath); + // Back to a healthy card; the state is process-wide, so injectors must reset it. void FatFsHostClearFaults(void); diff --git a/integration-tests/harness/stubs/fatfs/ff.h b/integration-tests/harness/stubs/fatfs/ff.h index f7312908..1ca2e73a 100644 --- a/integration-tests/harness/stubs/fatfs/ff.h +++ b/integration-tests/harness/stubs/fatfs/ff.h @@ -113,6 +113,11 @@ FRESULT f_write (FIL* fp, const void* buff, UINT btw, UINT* bw); FRESULT f_sync (FIL* fp); FRESULT f_lseek (FIL* fp, FSIZE_t ofs); +// Directory-entry manipulation, for the atomic-replace dance the Wi-Fi config +// upload performs. f_rename refuses an existing destination, as FatFs does. +FRESULT f_unlink (const TCHAR* path); +FRESULT f_rename (const TCHAR* path_old, const TCHAR* path_new); + // Directory walk: link-only stubs for mdsfile.cpp (MDS is not under test). FRESULT f_opendir (DIR* dp, const TCHAR* path); FRESULT f_readdir (DIR* dp, FILINFO* fno); diff --git a/integration-tests/test-suite/test_toolbox.cpp b/integration-tests/test-suite/test_toolbox.cpp index 6f3b97e6..bac03f43 100644 --- a/integration-tests/test-suite/test_toolbox.cpp +++ b/integration-tests/test-suite/test_toolbox.cpp @@ -1,7 +1,8 @@ // // test_toolbox.cpp // -// The vendor "SCSI Toolbox" command set (0xD0/0xD2/0xD7/0xD8/0xD9). +// The vendor "SCSI Toolbox" command set (0xD0/0xD2/0xD7/0xD8/0xD9), plus the +// send-file commands (0xD3/0xD4/0xD5) that carry a Wi-Fi configuration. // // This is USBODE's signature feature: it is how the DOS/host-side picker // enumerates the images on the SD card and swaps the disc without touching @@ -28,10 +29,18 @@ // the NUL rather than pinning heap contents. // #include "bench.h" +#include "fatfs_host.h" #include "framework.h" +#include +#include + +#include + +#include #include #include +#include // Check the defined bytes of directory entry `slot`: index, type, the name up // to and including its NUL, and the 40-bit big-endian size. Indexing at @@ -364,3 +373,1048 @@ TEST(toolbox_list_files_is_deterministic) CHECK_BYTES(second.data.data(), second.data.size(), first.data.data(), first.data.size()); } + +static const char kOriginalConfig[] = + "country=GB\n" + "ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev\n" + "network={\n\tssid=\"OldNetwork\"\n\tpsk=\"OldSecret123\"\n}\n"; + +// Distinctive enough that a log scan for it cannot match by accident. +static const char kPassword[] = "S3cretWiFiPassw0rd"; + +static std::string WiFiRoot() +{ +#ifdef USBODE_TESTDATA + return std::string(USBODE_TESTDATA) + "/wifiroot"; +#else + return "out/images/wifiroot"; +#endif +} + +static std::string RootPath(const char *pName) +{ + return WiFiRoot() + "/" + pName; +} + +static bool ReadRootFile(const char *pName, std::string &out) +{ + out.clear(); + FILE *f = fopen(RootPath(pName).c_str(), "rb"); + if (f == nullptr) + { + return false; + } + char buf[1024]; + size_t n; + while ((n = fread(buf, 1, sizeof(buf), f)) > 0) + { + out.append(buf, n); + } + fclose(f); + return true; +} + +static void WriteRootFile(const char *pName, const std::string &content) +{ + FILE *f = fopen(RootPath(pName).c_str(), "wb"); + CHECK(f != nullptr); + if (f == nullptr) + { + return; + } + fwrite(content.data(), 1, content.size(), f); + fclose(f); +} + +// Return the staging singleton to Idle whatever state a previous test left it +// in, so one test's leftovers cannot decide the next one's result. +static void DrainUpload() +{ + CWiFiConfigUpload &upload = CWiFiConfigUpload::Get(); + if (upload.CommitPending()) + { + upload.ProcessCommit(); + } + upload.Abort(); + upload.ConsumeRebootRequest(); +} + +// Points the firmware's "0:/..." paths at a scratch directory and seeds it +// with a working configuration, so "the original survived" is a real check. +struct WiFiFixture +{ + WiFiFixture() + { + mkdir(WiFiRoot().c_str(), 0777); + FatFsHostClearFaults(); + FatFsHostSetDriveRoot(WiFiRoot().c_str()); + DrainUpload(); + Clean(); + WriteRootFile("wpa_supplicant.conf", kOriginalConfig); + } + + ~WiFiFixture() + { + DrainUpload(); + FatFsHostClearFaults(); + Clean(); + FatFsHostSetDriveRoot(nullptr); + } + + void Clean() + { + remove(RootPath("wpa_supplicant.conf").c_str()); + remove(RootPath("wpa_supplicant.tmp").c_str()); + remove(RootPath("wpa_supplicant.bak").c_str()); + } +}; + +// Exactly nTotal bytes of configuration-shaped text. Truncation may remove +// kPassword, so secrecy tests must verify that their sample contains it. +static std::string MakeConfig(size_t nTotal) +{ + std::string s = "country=US\nctrl_interface=DIR=/var/run/wpa_supplicant\nupdate_config=1\n" + "network={\n\tssid=\"HomeNet\"\n\tpsk=\""; + s += kPassword; + s += "\"\n}\n"; + while (s.size() < nTotal) + { + s += "# padding so the upload spans more than one block\n"; + } + s.resize(nTotal); + return s; +} + +// TOOLBOX_SEND_FILE_PREP. The client declares 33 bytes and NUL-terminates the +// name inside them; nSupplied under nDeclared models a short data phase. +static CGadgetTestBench::Result SendFilePrep(CGadgetTestBench &bench, const char *pName, + u32 nDeclared = 33, size_t nSupplied = 33) +{ + u8 payload[64]; + memset(payload, 0, sizeof(payload)); + size_t len = strlen(pName); + if (len > 32) + { + len = 32; + } + memcpy(payload, pName, len); + + const u8 cdb[10] = {0xD3, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + return bench.SendCommand(cdb, sizeof(cdb), nDeclared, false, payload, nSupplied); +} + +// The same command with the parameter list handed over verbatim, for names +// that are not C strings (no terminator, embedded control bytes). +static CGadgetTestBench::Result SendFilePrepRaw(CGadgetTestBench &bench, const u8 *pName, + size_t nNameLength, u32 nDeclared = 33) +{ + const u8 cdb[10] = {0xD3, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + return bench.SendCommand(cdb, sizeof(cdb), nDeclared, false, pName, nNameLength); +} + +// TOOLBOX_SEND_FILE_10. The client always moves 512 bytes and says in the CDB +// how many of them are real; the filler here is what must never be written. +static CGadgetTestBench::Result SendFileBlock(CGadgetTestBench &bench, u32 nBlockIndex, + const void *pData, u16 nValidLength, + u32 nDeclared = 512, size_t nSupplied = 512) +{ + u8 payload[512]; + memset(payload, 0xAA, sizeof(payload)); + size_t copy = nValidLength; + if (copy > sizeof(payload)) + { + copy = sizeof(payload); + } + if (pData != nullptr && copy > 0) + { + memcpy(payload, pData, copy); + } + + const u8 cdb[10] = {0xD4, + (u8)(nValidLength >> 8), (u8)(nValidLength & 0xFF), + (u8)((nBlockIndex >> 16) & 0xFF), (u8)((nBlockIndex >> 8) & 0xFF), + (u8)(nBlockIndex & 0xFF), + 0x00, 0x00, 0x00, 0x00}; + return bench.SendCommand(cdb, sizeof(cdb), nDeclared, false, payload, nSupplied); +} + +// TOOLBOX_SEND_FILE_END. nDeclared 4 is what the DOS client sends; 0 is the +// documented no-payload form. +static CGadgetTestBench::Result SendFileEnd(CGadgetTestBench &bench, u32 nDeclared = 4) +{ + const u8 payload[4] = {0xDE, 0xAD, 0xBE, 0xEF}; + const u8 cdb[10] = {0xD5, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + return bench.SendCommand(cdb, sizeof(cdb), nDeclared, false, + nDeclared > 0 ? payload : nullptr, + nDeclared > 0 ? sizeof(payload) : 0); +} + +static bool UploadConfig(CGadgetTestBench &bench, const char *pName, + const std::string &content, u32 nEndLength = 4) +{ + if (SendFilePrep(bench, pName).csw.bmCSWStatus != 0) + { + return false; + } + size_t blocks = (content.size() + 511) / 512; + for (size_t i = 0; i < blocks; i++) + { + size_t off = i * 512; + size_t n = content.size() - off; + if (n > 512) + { + n = 512; + } + if (SendFileBlock(bench, (u32)i, content.data() + off, (u16)n).csw.bmCSWStatus != 0) + { + return false; + } + } + return SendFileEnd(bench, nEndLength).csw.bmCSWStatus == 0; +} + +// Sense key / ASC / ASCQ of the last failure, read the way a host reads it. +static void CheckSense(CGadgetTestBench &bench, u8 key, u8 asc, u8 ascq) +{ + auto sense = bench.RequestSense(); + CHECK_EQ(sense.data.size() >= (size_t)14, true); + if (sense.data.size() < 14) + { + return; + } + CHECK_EQ(sense.data[2] & 0x0F, key); + CHECK_EQ(sense.data[12], asc); + CHECK_EQ(sense.data[13], ascq); +} + +// 700 bytes is two blocks with a 188-byte final one: the shape of every real +// upload, and it has to land in 0:/wpa_supplicant.conf byte for byte. +TEST(sendfile_uploads_a_wifi_config) +{ + WiFiFixture fixture; + CFakeImageDevice *disc = MakeDataISO(1200); + SCSITBService tbservice; + CGadgetTestBench bench(disc, false, nullptr, nullptr, &tbservice); + bench.Activate(); + bench.RequestSense(); + + const std::string config = MakeConfig(700); + CHECK(UploadConfig(bench, "wpa_supplicant.conf", config)); + + // Nothing has touched the SD card yet: the commit is queued for the task + // context, and the reboot waits on the commit. + CHECK(CWiFiConfigUpload::Get().CommitPending()); + CHECK(!CWiFiConfigUpload::Get().ConsumeRebootRequest()); + std::string onDisk; + CHECK(ReadRootFile("wpa_supplicant.conf", onDisk)); + CHECK(onDisk == kOriginalConfig); + + CWiFiConfigUpload::Get().ProcessCommit(); + CHECK(ReadRootFile("wpa_supplicant.conf", onDisk)); + CHECK_EQ(onDisk.size(), config.size()); + CHECK(onDisk == config); + CHECK(CWiFiConfigUpload::Get().ConsumeRebootRequest()); + + CHECK(!CWiFiConfigUpload::Get().ConsumeRebootRequest()); + CHECK_EQ(CWiFiConfigUpload::Get().StagedLength(), 0u); + CHECK(!CWiFiConfigUpload::Get().CommitPending()); +} + +// The lengths the DOS client really declares (33 / 512 / 4). Accepting only +// the documented shapes would fail against the shipping client. +TEST(sendfile_accepts_the_dos_client_transfer_lengths) +{ + WiFiFixture fixture; + CFakeImageDevice *disc = MakeDataISO(1200); + SCSITBService tbservice; + CGadgetTestBench bench(disc, false, nullptr, nullptr, &tbservice); + bench.Activate(); + bench.RequestSense(); + + const std::string config = MakeConfig(100); + + auto prep = SendFilePrep(bench, "wpa_supplicant.conf", 33, 33); + CHECK_EQ(prep.csw.bmCSWStatus, 0); + CHECK_EQ(prep.csw.dCSWDataResidue, 0u); + + auto block = SendFileBlock(bench, 0, config.data(), 100, 512, 512); + CHECK_EQ(block.csw.bmCSWStatus, 0); + CHECK_EQ(block.csw.dCSWDataResidue, 0u); + + auto end = SendFileEnd(bench, 4); + CHECK_EQ(end.csw.bmCSWStatus, 0); + CHECK_EQ(end.csw.dCSWDataResidue, 0u); + + CWiFiConfigUpload::Get().ProcessCommit(); + std::string onDisk; + CHECK(ReadRootFile("wpa_supplicant.conf", onDisk)); + CHECK(onDisk == config); +} + +// toolbox.h documents SEND_FILE_END as taking no data at all. Supporting both +// keeps any client that follows the document working. +TEST(sendfile_end_accepts_the_documented_no_payload_form) +{ + WiFiFixture fixture; + CFakeImageDevice *disc = MakeDataISO(1200); + SCSITBService tbservice; + CGadgetTestBench bench(disc, false, nullptr, nullptr, &tbservice); + bench.Activate(); + bench.RequestSense(); + + const std::string config = MakeConfig(64); + CHECK(UploadConfig(bench, "wpa_supplicant.conf", config, 0)); + + CWiFiConfigUpload::Get().ProcessCommit(); + std::string onDisk; + CHECK(ReadRootFile("wpa_supplicant.conf", onDisk)); + CHECK(onDisk == config); +} + +// WIFI.CFG is the other name the tooling uses, and DOS clients upper-case +// their filenames, so the match has to be case insensitive. +TEST(sendfile_accepts_wifi_cfg_case_insensitively) +{ + WiFiFixture fixture; + CFakeImageDevice *disc = MakeDataISO(1200); + SCSITBService tbservice; + CGadgetTestBench bench(disc, false, nullptr, nullptr, &tbservice); + bench.Activate(); + bench.RequestSense(); + + const std::string config = MakeConfig(64); + CHECK(UploadConfig(bench, "WIFI.CFG", config)); + CWiFiConfigUpload::Get().ProcessCommit(); + + // Whichever name was uploaded, the bytes land in wpa_supplicant.conf. + std::string onDisk; + CHECK(ReadRootFile("wpa_supplicant.conf", onDisk)); + CHECK(onDisk == config); + CHECK(!ReadRootFile("WIFI.CFG", onDisk)); + + CGadgetTestBench bench2(disc, false, nullptr, nullptr, &tbservice); + bench2.Activate(); + bench2.RequestSense(); + CHECK(UploadConfig(bench2, "Wpa_Supplicant.CONF", config)); + CWiFiConfigUpload::Get().ProcessCommit(); + CHECK(ReadRootFile("wpa_supplicant.conf", onDisk)); + CHECK(onDisk == config); +} + +// The block index is an absolute position, not an append cursor: a client +// retrying a block after a bus reset must not lengthen the file. +TEST(sendfile_places_blocks_absolutely_and_retries_in_place) +{ + WiFiFixture fixture; + CFakeImageDevice *disc = MakeDataISO(1200); + SCSITBService tbservice; + CGadgetTestBench bench(disc, false, nullptr, nullptr, &tbservice); + bench.Activate(); + bench.RequestSense(); + + std::string block0(512, 'A'); + std::string block1(200, 'B'); + std::string retry1(200, 'C'); + + CHECK_EQ(SendFilePrep(bench, "wpa_supplicant.conf").csw.bmCSWStatus, 0); + CHECK_EQ(SendFileBlock(bench, 0, block0.data(), 512).csw.bmCSWStatus, 0); + CHECK_EQ(SendFileBlock(bench, 1, block1.data(), 200).csw.bmCSWStatus, 0); + CHECK_EQ(CWiFiConfigUpload::Get().StagedLength(), 712u); + + CHECK_EQ(SendFileBlock(bench, 1, retry1.data(), 200).csw.bmCSWStatus, 0); + CHECK_EQ(CWiFiConfigUpload::Get().StagedLength(), 712u); + + std::string rewrite0(512, 'D'); + CHECK_EQ(SendFileBlock(bench, 0, rewrite0.data(), 512).csw.bmCSWStatus, 0); + CHECK_EQ(CWiFiConfigUpload::Get().StagedLength(), 712u); + + CHECK_EQ(SendFileEnd(bench).csw.bmCSWStatus, 0); + CWiFiConfigUpload::Get().ProcessCommit(); + + std::string onDisk; + CHECK(ReadRootFile("wpa_supplicant.conf", onDisk)); + CHECK_EQ(onDisk.size(), (size_t)712); + CHECK(onDisk == rewrite0 + retry1); +} + +// The final block still moves 512 bytes; only the CDB count is the file. +// Writing the padding would append hundreds of junk bytes to every config. +TEST(sendfile_partial_block_writes_only_the_declared_bytes) +{ + WiFiFixture fixture; + CFakeImageDevice *disc = MakeDataISO(1200); + SCSITBService tbservice; + CGadgetTestBench bench(disc, false, nullptr, nullptr, &tbservice); + bench.Activate(); + bench.RequestSense(); + + const std::string config = MakeConfig(300); + CHECK(UploadConfig(bench, "wpa_supplicant.conf", config)); + CWiFiConfigUpload::Get().ProcessCommit(); + + std::string onDisk; + CHECK(ReadRootFile("wpa_supplicant.conf", onDisk)); + CHECK_EQ(onDisk.size(), (size_t)300); + CHECK(onDisk.find('\xAA') == std::string::npos); + CHECK(onDisk == config); +} + +// A block with no PREP behind it has nowhere to go. Accepting it would mean +// the staging buffer's contents came from an unknown command sequence. +TEST(sendfile_block_without_prep_fails_closed) +{ + WiFiFixture fixture; + CFakeImageDevice *disc = MakeDataISO(1200); + SCSITBService tbservice; + CGadgetTestBench bench(disc, false, nullptr, nullptr, &tbservice); + bench.Activate(); + bench.RequestSense(); + + std::string data(64, 'x'); + auto r = SendFileBlock(bench, 0, data.data(), 64); + CHECK_EQ(r.csw.bmCSWStatus, 1); + CHECK_EQ(r.csw.dCSWDataResidue, 512u); + CHECK(r.stalledOut); + CheckSense(bench, 0x05, 0x2c, 0x00); // COMMAND SEQUENCE ERROR + + CHECK_EQ(CWiFiConfigUpload::Get().StagedLength(), 0u); + std::string onDisk; + CHECK(ReadRootFile("wpa_supplicant.conf", onDisk)); + CHECK(onDisk == kOriginalConfig); +} + +// Likewise END: with nothing staged there is nothing to install, and a commit +// of an empty buffer would truncate a working configuration to zero bytes. +TEST(sendfile_end_without_prep_fails_closed) +{ + WiFiFixture fixture; + CFakeImageDevice *disc = MakeDataISO(1200); + SCSITBService tbservice; + CGadgetTestBench bench(disc, false, nullptr, nullptr, &tbservice); + bench.Activate(); + bench.RequestSense(); + + auto r = SendFileEnd(bench); + CHECK_EQ(r.csw.bmCSWStatus, 1); + CheckSense(bench, 0x05, 0x2c, 0x00); + CHECK(!CWiFiConfigUpload::Get().CommitPending()); + + // The no-payload form has to fail the same way rather than commit nothing. + auto documented = SendFileEnd(bench, 0); + CHECK_EQ(documented.csw.bmCSWStatus, 1); + CheckSense(bench, 0x05, 0x2c, 0x00); + CHECK(!CWiFiConfigUpload::Get().CommitPending()); + + std::string onDisk; + CHECK(ReadRootFile("wpa_supplicant.conf", onDisk)); + CHECK(onDisk == kOriginalConfig); +} + +// A PREP mid-transfer means the client restarted; the first attempt's tail +// must not survive under a shorter second upload. +TEST(sendfile_second_prep_abandons_the_first_upload) +{ + WiFiFixture fixture; + CFakeImageDevice *disc = MakeDataISO(1200); + SCSITBService tbservice; + CGadgetTestBench bench(disc, false, nullptr, nullptr, &tbservice); + bench.Activate(); + bench.RequestSense(); + + std::string abandoned(512, 'Z'); + CHECK_EQ(SendFilePrep(bench, "wpa_supplicant.conf").csw.bmCSWStatus, 0); + CHECK_EQ(SendFileBlock(bench, 0, abandoned.data(), 512).csw.bmCSWStatus, 0); + CHECK_EQ(CWiFiConfigUpload::Get().StagedLength(), 512u); + + CHECK_EQ(SendFilePrep(bench, "wpa_supplicant.conf").csw.bmCSWStatus, 0); + CHECK_EQ(CWiFiConfigUpload::Get().StagedLength(), 0u); + + const std::string config = MakeConfig(40); + CHECK_EQ(SendFileBlock(bench, 0, config.data(), 40).csw.bmCSWStatus, 0); + CHECK_EQ(SendFileEnd(bench).csw.bmCSWStatus, 0); + CWiFiConfigUpload::Get().ProcessCommit(); + + std::string onDisk; + CHECK(ReadRootFile("wpa_supplicant.conf", onDisk)); + CHECK_EQ(onDisk.size(), (size_t)40); + CHECK(onDisk == config); + + // A rejected second PREP still abandons the first: the client asked for a + // different destination, so what it staged before is meaningless. + CHECK_EQ(SendFilePrep(bench, "wpa_supplicant.conf").csw.bmCSWStatus, 0); + CHECK_EQ(SendFileBlock(bench, 0, abandoned.data(), 512).csw.bmCSWStatus, 0); + CHECK_EQ(SendFilePrep(bench, "autoexec.bat").csw.bmCSWStatus, 1); + CHECK_EQ(CWiFiConfigUpload::Get().StagedLength(), 0u); + CHECK(!CWiFiConfigUpload::Get().IsReceiving()); +} + +// 8 KiB is the documented cap. A block index past it is refused before +// anything is copied, so the overflow never reaches the staging buffer. +TEST(sendfile_oversized_upload_is_refused) +{ + WiFiFixture fixture; + CFakeImageDevice *disc = MakeDataISO(1200); + SCSITBService tbservice; + CGadgetTestBench bench(disc, false, nullptr, nullptr, &tbservice); + bench.Activate(); + bench.RequestSense(); + + std::string block(512, 'q'); + CHECK_EQ(SendFilePrep(bench, "wpa_supplicant.conf").csw.bmCSWStatus, 0); + + // Blocks 0..15 fill the buffer exactly; block 16 is one too many. + for (u32 i = 0; i < 16; i++) + { + CHECK_EQ(SendFileBlock(bench, i, block.data(), 512).csw.bmCSWStatus, 0); + } + CHECK_EQ(CWiFiConfigUpload::Get().StagedLength(), 8192u); + + auto over = SendFileBlock(bench, 16, block.data(), 512); + CHECK_EQ(over.csw.bmCSWStatus, 1); + CheckSense(bench, 0x05, 0x24, 0x00); // INVALID FIELD IN CDB + CHECK(!CWiFiConfigUpload::Get().IsReceiving()); + + // The rejected block took the whole upload with it, so END has nothing. + CHECK_EQ(SendFileEnd(bench).csw.bmCSWStatus, 1); + std::string onDisk; + CHECK(ReadRootFile("wpa_supplicant.conf", onDisk)); + CHECK(onDisk == kOriginalConfig); + + // The largest 24-bit index the CDB can hold must not overflow into a + // usable offset either. + CHECK_EQ(SendFilePrep(bench, "wpa_supplicant.conf").csw.bmCSWStatus, 0); + CHECK_EQ(SendFileBlock(bench, 0xFFFFFF, block.data(), 512).csw.bmCSWStatus, 1); + CheckSense(bench, 0x05, 0x24, 0x00); +} + +// A gap would leave NUL bytes mid-configuration. The client is strictly +// sequential, so a jump forward is malformed, not a sparse write. +TEST(sendfile_refuses_a_gap_between_blocks) +{ + WiFiFixture fixture; + CFakeImageDevice *disc = MakeDataISO(1200); + SCSITBService tbservice; + CGadgetTestBench bench(disc, false, nullptr, nullptr, &tbservice); + bench.Activate(); + bench.RequestSense(); + + std::string block(512, 'g'); + CHECK_EQ(SendFilePrep(bench, "wpa_supplicant.conf").csw.bmCSWStatus, 0); + CHECK_EQ(SendFileBlock(bench, 0, block.data(), 512).csw.bmCSWStatus, 0); + + auto gap = SendFileBlock(bench, 3, block.data(), 512); + CHECK_EQ(gap.csw.bmCSWStatus, 1); + CheckSense(bench, 0x05, 0x24, 0x00); + CHECK(!CWiFiConfigUpload::Get().IsReceiving()); +} + +// Not a general file transfer: every name but the two Wi-Fi ones is refused, +// including the traversal and drive-prefix shapes that reach the rest of the card. +TEST(sendfile_rejects_every_other_destination) +{ + WiFiFixture fixture; + CFakeImageDevice *disc = MakeDataISO(1200); + SCSITBService tbservice; + CGadgetTestBench bench(disc, false, nullptr, nullptr, &tbservice); + bench.Activate(); + bench.RequestSense(); + + const char *rejected[] = { + "config.txt", + "image.iso", + "wpa_supplicant.con", + "wpa_supplicant.confx", + "../wpa_supplicant.conf", + "..\\wpa_supplicant.conf", + "/wpa_supplicant.conf", + "0:/wpa_supplicant.conf", + "C:\\wpa_supplicant.conf", + "subdir/wpa_supplicant.conf", + "..", + "", + }; + + for (size_t i = 0; i < sizeof(rejected) / sizeof(rejected[0]); i++) + { + auto r = SendFilePrep(bench, rejected[i]); + CHECK_EQ(r.csw.bmCSWStatus, 1); + CheckSense(bench, 0x05, 0x26, 0x00); // INVALID FIELD IN PARAMETER LIST + CHECK(!CWiFiConfigUpload::Get().IsReceiving()); + } + + // A name with an embedded control byte, and one with no terminator at all + // inside the 33-byte field. + u8 control[33]; + memset(control, 0, sizeof(control)); + memcpy(control, "wpa_supplicant\x01.conf", 19); + CHECK_EQ(SendFilePrepRaw(bench, control, sizeof(control)).csw.bmCSWStatus, 1); + CheckSense(bench, 0x05, 0x26, 0x00); + + u8 unterminated[33]; + memset(unterminated, 'A', sizeof(unterminated)); + CHECK_EQ(SendFilePrepRaw(bench, unterminated, sizeof(unterminated)).csw.bmCSWStatus, 1); + CheckSense(bench, 0x05, 0x26, 0x00); + + std::string onDisk; + CHECK(ReadRootFile("wpa_supplicant.conf", onDisk)); + CHECK(onDisk == kOriginalConfig); +} + +// Lengths that describe no performable transfer are refused before any buffer +// is read, so the handler cannot walk past what actually arrived. +TEST(sendfile_rejects_bad_transfer_lengths) +{ + WiFiFixture fixture; + CFakeImageDevice *disc = MakeDataISO(1200); + SCSITBService tbservice; + CGadgetTestBench bench(disc, false, nullptr, nullptr, &tbservice); + bench.Activate(); + bench.RequestSense(); + + CHECK_EQ(SendFilePrep(bench, "wpa_supplicant.conf", 0, 0).csw.bmCSWStatus, 1); + CheckSense(bench, 0x05, 0x1a, 0x00); // PARAMETER LIST LENGTH ERROR + CHECK_EQ(SendFilePrep(bench, "wpa_supplicant.conf", 64, 64).csw.bmCSWStatus, 1); + CheckSense(bench, 0x05, 0x1a, 0x00); + + // A PREP whose data phase is cut short before the name's terminator. + CHECK_EQ(SendFilePrep(bench, "wpa_supplicant.conf", 33, 5).csw.bmCSWStatus, 1); + CheckSense(bench, 0x05, 0x26, 0x00); + + std::string data(512, 'y'); + CHECK_EQ(SendFilePrep(bench, "wpa_supplicant.conf").csw.bmCSWStatus, 0); + + CHECK_EQ(SendFileBlock(bench, 0, data.data(), 0).csw.bmCSWStatus, 1); + CheckSense(bench, 0x05, 0x24, 0x00); + + CHECK_EQ(SendFilePrep(bench, "wpa_supplicant.conf").csw.bmCSWStatus, 0); + CHECK_EQ(SendFileBlock(bench, 0, data.data(), 513, 512, 512).csw.bmCSWStatus, 1); + CheckSense(bench, 0x05, 0x24, 0x00); + + // A CDB claiming more valid bytes than the host actually moved. + CHECK_EQ(SendFilePrep(bench, "wpa_supplicant.conf").csw.bmCSWStatus, 0); + auto shortPhase = SendFileBlock(bench, 0, data.data(), 512, 512, 100); + CHECK_EQ(shortPhase.csw.bmCSWStatus, 1); + CheckSense(bench, 0x05, 0x1a, 0x00); + CHECK(!CWiFiConfigUpload::Get().IsReceiving()); + + // A declared transfer larger than the gadget's OUT buffer. + CHECK_EQ(SendFilePrep(bench, "wpa_supplicant.conf").csw.bmCSWStatus, 0); + CHECK_EQ(SendFileBlock(bench, 0, data.data(), 512, 4096).csw.bmCSWStatus, 1); + CheckSense(bench, 0x05, 0x1a, 0x00); + + std::string onDisk; + CHECK(ReadRootFile("wpa_supplicant.conf", onDisk)); + CHECK(onDisk == kOriginalConfig); +} + +// An upload the client never finished must leave the working configuration +// exactly as it was, not a truncated version of the new one. +TEST(sendfile_interrupted_transfer_leaves_the_original_intact) +{ + WiFiFixture fixture; + CFakeImageDevice *disc = MakeDataISO(1200); + SCSITBService tbservice; + CGadgetTestBench bench(disc, false, nullptr, nullptr, &tbservice); + bench.Activate(); + bench.RequestSense(); + + const std::string config = MakeConfig(900); + CHECK_EQ(SendFilePrep(bench, "wpa_supplicant.conf").csw.bmCSWStatus, 0); + CHECK_EQ(SendFileBlock(bench, 0, config.data(), 512).csw.bmCSWStatus, 0); + // ...and the client goes away here: no second block, no END. + + CHECK(!CWiFiConfigUpload::Get().CommitPending()); + std::string onDisk; + CHECK(ReadRootFile("wpa_supplicant.conf", onDisk)); + CHECK_BYTES(onDisk.data(), onDisk.size(), kOriginalConfig, strlen(kOriginalConfig)); + + // No temporary file was left behind for the next boot to trip over. + std::string leftover; + CHECK(!ReadRootFile("wpa_supplicant.tmp", leftover)); +} + +// A full card reports FR_OK with a short write count. Trusting the result code +// would install a truncated configuration and lose the working one. +TEST(sendfile_short_write_does_not_replace_the_original) +{ + WiFiFixture fixture; + CFakeImageDevice *disc = MakeDataISO(1200); + SCSITBService tbservice; + CGadgetTestBench bench(disc, false, nullptr, nullptr, &tbservice); + bench.Activate(); + bench.RequestSense(); + + const std::string config = MakeConfig(600); + CHECK(UploadConfig(bench, "wpa_supplicant.conf", config)); + + FatFsHostSetWriteLimit(100); + CWiFiConfigUpload::Get().ProcessCommit(); + FatFsHostClearFaults(); + + std::string onDisk; + CHECK(ReadRootFile("wpa_supplicant.conf", onDisk)); + CHECK(onDisk == kOriginalConfig); + CHECK(!CWiFiConfigUpload::Get().ConsumeRebootRequest()); + + std::string leftover; + CHECK(!ReadRootFile("wpa_supplicant.tmp", leftover)); + CHECK_EQ(CWiFiConfigUpload::Get().StagedLength(), 0u); +} + +// Bytes that reached the FAT cache but not the card are not committed. Without +// the sync check a power cut right after the rename loses both files. +TEST(sendfile_sync_failure_does_not_replace_the_original) +{ + WiFiFixture fixture; + CFakeImageDevice *disc = MakeDataISO(1200); + SCSITBService tbservice; + CGadgetTestBench bench(disc, false, nullptr, nullptr, &tbservice); + bench.Activate(); + bench.RequestSense(); + + CHECK(UploadConfig(bench, "wpa_supplicant.conf", MakeConfig(200))); + + FatFsHostFailSync(true); + CWiFiConfigUpload::Get().ProcessCommit(); + FatFsHostClearFaults(); + + std::string onDisk; + CHECK(ReadRootFile("wpa_supplicant.conf", onDisk)); + CHECK(onDisk == kOriginalConfig); + CHECK(!CWiFiConfigUpload::Get().ConsumeRebootRequest()); + + std::string leftover; + CHECK(!ReadRootFile("wpa_supplicant.tmp", leftover)); +} + +// FAT cannot replace a file atomically. Both renames are failed in turn here, +// because moving the old file aside and installing the new one roll back differently. +TEST(sendfile_rename_failure_restores_the_original) +{ + WiFiFixture fixture; + CFakeImageDevice *disc = MakeDataISO(1200); + SCSITBService tbservice; + CGadgetTestBench bench(disc, false, nullptr, nullptr, &tbservice); + bench.Activate(); + bench.RequestSense(); + + // First rename: the old file cannot be moved out of the way at all. + CHECK(UploadConfig(bench, "wpa_supplicant.conf", MakeConfig(200))); + FatFsHostFailRename(1); + CWiFiConfigUpload::Get().ProcessCommit(); + FatFsHostClearFaults(); + + std::string onDisk; + CHECK(ReadRootFile("wpa_supplicant.conf", onDisk)); + CHECK(onDisk == kOriginalConfig); + CHECK(!CWiFiConfigUpload::Get().ConsumeRebootRequest()); + std::string leftover; + CHECK(!ReadRootFile("wpa_supplicant.tmp", leftover)); + + // Second rename: the old file is already aside, so the rollback has to + // bring it back rather than leave the drive with no configuration. + CGadgetTestBench bench2(disc, false, nullptr, nullptr, &tbservice); + bench2.Activate(); + bench2.RequestSense(); + CHECK(UploadConfig(bench2, "wpa_supplicant.conf", MakeConfig(200))); + FatFsHostFailRename(2); + CWiFiConfigUpload::Get().ProcessCommit(); + FatFsHostClearFaults(); + + CHECK(ReadRootFile("wpa_supplicant.conf", onDisk)); + CHECK(onDisk == kOriginalConfig); + CHECK(!CWiFiConfigUpload::Get().ConsumeRebootRequest()); + CHECK(!ReadRootFile("wpa_supplicant.tmp", leftover)); +} + +// Only the documented no-payload form and the DOS client's four bytes are +// accepted; any other length is a client this device has not been proven with. +TEST(sendfile_end_rejects_every_other_payload_length) +{ + WiFiFixture fixture; + CFakeImageDevice *disc = MakeDataISO(1200); + SCSITBService tbservice; + CGadgetTestBench bench(disc, false, nullptr, nullptr, &tbservice); + bench.Activate(); + bench.RequestSense(); + + const u32 rejected[] = {1, 5, 512}; + const std::string config = MakeConfig(200); + + for (size_t i = 0; i < sizeof(rejected) / sizeof(rejected[0]); i++) + { + const u32 declared = rejected[i]; + CHECK_EQ(SendFilePrep(bench, "wpa_supplicant.conf").csw.bmCSWStatus, 0); + CHECK_EQ(SendFileBlock(bench, 0, config.data(), 200).csw.bmCSWStatus, 0); + CHECK_EQ(CWiFiConfigUpload::Get().StagedLength(), 200u); + + auto r = SendFileEnd(bench, declared); + CHECK_EQ(r.csw.bmCSWStatus, 1); + CHECK_EQ(r.csw.dCSWDataResidue, declared); + CHECK(r.stalledOut); + CheckSense(bench, 0x05, 0x1a, 0x00); // PARAMETER LIST LENGTH ERROR + + CHECK(!CWiFiConfigUpload::Get().IsReceiving()); + CHECK(!CWiFiConfigUpload::Get().CommitPending()); + CHECK_EQ(CWiFiConfigUpload::Get().StagedLength(), 0u); + + std::string onDisk; + CHECK(ReadRootFile("wpa_supplicant.conf", onDisk)); + CHECK_BYTES(onDisk.data(), onDisk.size(), kOriginalConfig, strlen(kOriginalConfig)); + } + + CHECK(UploadConfig(bench, "wpa_supplicant.conf", config, 4)); + CWiFiConfigUpload::Get().ProcessCommit(); + CHECK(UploadConfig(bench, "wpa_supplicant.conf", config, 0)); + CWiFiConfigUpload::Get().ProcessCommit(); + + std::string onDisk; + CHECK(ReadRootFile("wpa_supplicant.conf", onDisk)); + CHECK(onDisk == config); +} + +// The staging buffer is erased after a commit, so a later shorter upload +// cannot carry a tail of the previous configuration's password onto the card. +TEST(sendfile_commit_leaves_no_residue_for_the_next_upload) +{ + WiFiFixture fixture; + CFakeImageDevice *disc = MakeDataISO(1200); + SCSITBService tbservice; + CGadgetTestBench bench(disc, false, nullptr, nullptr, &tbservice); + bench.Activate(); + bench.RequestSense(); + + const std::string first = MakeConfig(1200); + CHECK(first.find(kPassword) != std::string::npos); + CHECK(UploadConfig(bench, "wpa_supplicant.conf", first)); + CWiFiConfigUpload::Get().ProcessCommit(); + + const std::string second = "country=US\nnetwork={\n\tssid=\"Other\"\n}\n"; + CHECK(UploadConfig(bench, "wpa_supplicant.conf", second)); + CWiFiConfigUpload::Get().ProcessCommit(); + + std::string onDisk; + CHECK(ReadRootFile("wpa_supplicant.conf", onDisk)); + CHECK_EQ(onDisk.size(), second.size()); + CHECK(onDisk == second); + CHECK(onDisk.find(kPassword) == std::string::npos); +} + +// If the rollback fails too, the old configuration exists only under the +// backup name; the next attempt must not clear it before one has succeeded. +TEST(sendfile_failed_rollback_keeps_the_original_as_a_backup) +{ + WiFiFixture fixture; + CFakeImageDevice *disc = MakeDataISO(1200); + SCSITBService tbservice; + CGadgetTestBench bench(disc, false, nullptr, nullptr, &tbservice); + bench.Activate(); + bench.RequestSense(); + + // Install and rollback both fail: the old file is stranded at its backup + // name, and no new configuration is installed. + CHECK(UploadConfig(bench, "wpa_supplicant.conf", MakeConfig(200))); + FatFsHostFailRename(2, 2); + CWiFiConfigUpload::Get().ProcessCommit(); + FatFsHostClearFaults(); + + std::string onDisk; + std::string backup; + CHECK(!ReadRootFile("wpa_supplicant.conf", onDisk)); + CHECK(ReadRootFile("wpa_supplicant.bak", backup)); + CHECK(backup == kOriginalConfig); + CHECK(!CWiFiConfigUpload::Get().ConsumeRebootRequest()); + CHECK(!ReadRootFile("wpa_supplicant.tmp", onDisk)); + + // A further failed attempt must leave that rescued copy alone: it is the + // only configuration the card still has. + CGadgetTestBench bench2(disc, false, nullptr, nullptr, &tbservice); + bench2.Activate(); + bench2.RequestSense(); + CHECK(UploadConfig(bench2, "wpa_supplicant.conf", MakeConfig(200))); + FatFsHostFailRename(1, 1); + CWiFiConfigUpload::Get().ProcessCommit(); + FatFsHostClearFaults(); + + CHECK(ReadRootFile("wpa_supplicant.bak", backup)); + CHECK(backup == kOriginalConfig); + CHECK(!ReadRootFile("wpa_supplicant.conf", onDisk)); + + // Once an install does succeed the backup has been superseded and goes. + CGadgetTestBench bench3(disc, false, nullptr, nullptr, &tbservice); + bench3.Activate(); + bench3.RequestSense(); + const std::string config = MakeConfig(120); + CHECK(UploadConfig(bench3, "wpa_supplicant.conf", config)); + CWiFiConfigUpload::Get().ProcessCommit(); + + CHECK(ReadRootFile("wpa_supplicant.conf", onDisk)); + CHECK(onDisk == config); + CHECK(!ReadRootFile("wpa_supplicant.bak", backup)); + CHECK(CWiFiConfigUpload::Get().ConsumeRebootRequest()); +} + +// A first-time setup has no configuration to move aside. The missing file must +// read as "nothing to back up", not as a failure. +TEST(sendfile_installs_when_no_configuration_exists_yet) +{ + WiFiFixture fixture; + fixture.Clean(); + + CFakeImageDevice *disc = MakeDataISO(1200); + SCSITBService tbservice; + CGadgetTestBench bench(disc, false, nullptr, nullptr, &tbservice); + bench.Activate(); + bench.RequestSense(); + + const std::string config = MakeConfig(120); + CHECK(UploadConfig(bench, "wpa_supplicant.conf", config)); + CWiFiConfigUpload::Get().ProcessCommit(); + + std::string onDisk; + CHECK(ReadRootFile("wpa_supplicant.conf", onDisk)); + CHECK(onDisk == config); + CHECK(CWiFiConfigUpload::Get().ConsumeRebootRequest()); +} + +// Routed wrongly, ProcessOut() reads an upload as a mode page. This block is +// shaped exactly like the CD audio control page that moves the volume. +TEST(sendfile_payload_never_reaches_the_mode_select_parser) +{ + WiFiFixture fixture; + CFakeImageDevice *disc = MakeDataISO(1200); + SCSITBService tbservice; + CCDPlayer player; + CGadgetTestBench bench(disc, false, &player, nullptr, &tbservice); + bench.Activate(); + bench.RequestSense(); + + u8 payload[512]; + memset(payload, 0, sizeof(payload)); + payload[8] = 0x0E; // page code: CD audio control + payload[8 + 1] = 0x0E; // page length + payload[8 + 9] = 0x55; // output 0 volume + payload[8 + 11] = 0x55; // output 1 volume + + const u8 volumeBefore = player.volume; + CHECK_EQ(SendFilePrep(bench, "wpa_supplicant.conf").csw.bmCSWStatus, 0); + CHECK_EQ(SendFileBlock(bench, 0, payload, 512).csw.bmCSWStatus, 0); + CHECK_EQ(SendFileEnd(bench).csw.bmCSWStatus, 0); + + CHECK_EQ(player.setVolumeCalls, 0); + CHECK_EQ(player.volume, volumeBefore); +} + +// Same rule the other toolbox commands follow: a read left pending by an +// aborted transfer must not resume and stream sectors into the upload. +TEST(sendfile_does_not_resume_a_pending_read) +{ + WiFiFixture fixture; + CFakeImageDevice *disc = MakeDataISO(1200); + SCSITBService tbservice; + CGadgetTestBench bench(disc, false, nullptr, nullptr, &tbservice); + bench.Activate(); + bench.RequestSense(); + + const std::string config = MakeConfig(64); + + bench.SetPendingBlocks(5); + auto prep = SendFilePrep(bench, "wpa_supplicant.conf"); + CHECK_EQ(prep.csw.bmCSWStatus, 0); + CHECK_EQ(prep.data.size(), (size_t)0); + + bench.SetPendingBlocks(5); + auto block = SendFileBlock(bench, 0, config.data(), 64); + CHECK_EQ(block.csw.bmCSWStatus, 0); + CHECK_EQ(block.data.size(), (size_t)0); + + bench.SetPendingBlocks(5); + auto end = SendFileEnd(bench); + CHECK_EQ(end.csw.bmCSWStatus, 0); + CHECK_EQ(end.data.size(), (size_t)0); + + CWiFiConfigUpload::Get().ProcessCommit(); + std::string onDisk; + CHECK(ReadRootFile("wpa_supplicant.conf", onDisk)); + CHECK(onDisk == config); +} + +// The staged bytes are a Wi-Fi password. Nothing on this path may put them in +// the log, which USBODE writes to the SD card and users attach to bug reports. +TEST(sendfile_never_logs_the_payload) +{ + WiFiFixture fixture; + CFakeImageDevice *disc = MakeDataISO(1200); + SCSITBService tbservice; + ConfigService config; + config.debugCdrom = true; // the loudest the gadget ever gets + CGadgetTestBench bench(disc, false, nullptr, &config, &tbservice); + bench.Activate(); + bench.RequestSense(); + + CLogger::TestClearEvents(); + + const std::string content = MakeConfig(700); + CHECK(content.find(kPassword) != std::string::npos); + CHECK(UploadConfig(bench, "wpa_supplicant.conf", content)); + CWiFiConfigUpload::Get().ProcessCommit(); + + // A rejected upload is just as sensitive: the client may well have sent + // the real password before the name was refused. + CHECK_EQ(SendFilePrep(bench, "wpa_supplicant.conf").csw.bmCSWStatus, 0); + CHECK_EQ(SendFileBlock(bench, 0, content.data(), 512).csw.bmCSWStatus, 0); + CHECK_EQ(SendFileBlock(bench, 9, content.data(), 512).csw.bmCSWStatus, 1); + + bool bSawUploadLine = false; + TLogSeverity severity; + char source[LOG_MAX_SOURCE]; + char message[LOG_MAX_MESSAGE]; + while (CLogger::Get()->ReadEvent(&severity, source, message, nullptr, nullptr, nullptr)) + { + std::string text(message); + CHECK(text.find(kPassword) == std::string::npos); + CHECK(text.find("HomeNet") == std::string::npos); + CHECK(text.find("ctrl_interface") == std::string::npos); + if (text.find("wpa_supplicant.conf") != std::string::npos) + { + bSawUploadLine = true; + } + } + + // The accepted destination is logged, so an operator can see what happened. + CHECK(bSawUploadLine); +} + +// Listing and disc selection share the gadget's OUT buffer and sense data with +// the upload path, so they are re-run afterwards to show nothing was left behind. +TEST(sendfile_leaves_the_other_toolbox_commands_working) +{ + WiFiFixture fixture; + CFakeImageDevice *disc = MakeDataISO(1200); + SCSITBService tbservice; + FillCatalog(tbservice, 3); + CGadgetTestBench bench(disc, false, nullptr, nullptr, &tbservice); + bench.Activate(); + bench.RequestSense(); + + CHECK(UploadConfig(bench, "wpa_supplicant.conf", MakeConfig(600))); + CWiFiConfigUpload::Get().ProcessCommit(); + + auto devices = Toolbox(bench, 0xD9, 8); + CHECK_EQ(devices.csw.bmCSWStatus, 0); + const u8 expected[8] = {0x02, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; + CHECK_BYTES(devices.data.data(), devices.data.size(), expected, sizeof(expected)); + + auto count = Toolbox(bench, 0xD2, 1); + CHECK_EQ(count.csw.bmCSWStatus, 0); + CHECK_EQ(count.data[0], 3); + + auto files = Toolbox(bench, 0xD0, 3 * 40); + CHECK_EQ(files.csw.bmCSWStatus, 0); + CHECK_EQ(files.data.size(), (size_t)(3 * 40)); + CheckEntry(files.data, 1, 1, 0, "image01.iso", 1007); + + auto select = Toolbox(bench, 0xD8, 0, 2); + CHECK_EQ(select.csw.bmCSWStatus, 0); + CHECK_EQ(tbservice.lastSetNextCD, 2); +} diff --git a/version.txt b/version.txt index 351227fc..18091983 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -3.2.4 +3.4.0