Compare commits

...

5 Commits

Author SHA1 Message Date
Julien Calixte
a15789a1b4 feat(firmware): add on-device git push spike (git_push)
Wi-Fi + SNTP + flash-FAT + libgit2 in one bench binary: init a fresh working
copy, commit, and push a per-boot device/<unix> branch over mbedTLS HTTPS with
PAT auth. Gated behind the `git` feature; built/flashed via `just flash-git-push`.

Status: local git init verified on hardware; the ODB-write fix and the full
push are not yet confirmed end-to-end on device. Cert verification is bypassed
in the push callback (spike shortcut) -- real trust-store wiring must land
before this leaves the bench.
2026-07-06 00:18:30 +02:00
Julien Calixte
63ffd7e37a feat(firmware): bake and document git push env vars
build.rs embeds TW_REMOTE_URL / TW_GH_USER / TW_PAT / TW_AUTHOR_* via env!()
so only the git_push binary carries them (the editor references none), and
.env.example documents them. NOTE: TW_PAT lands in the git_push flash image
-- a bench-only shortcut (ADR-005); a product must not embed the PAT.
2026-07-06 00:18:22 +02:00
Julien Calixte
8d80168bda fix(libgit2): make the loose ODB persist on FATFS via POSIX shims
The loose ODB silently dropped every write on device: utimes() returned 0
unconditionally, so libgit2's freshen probe (git_futils_touch -> p_utimes)
always reported "object exists" and git_odb_write skipped the write entirely
-- blobs/trees/commits never hit disk and write_tree failed with "invalid
object specified". Gate utimes on file existence (present -> 0, absent ->
ENOENT). Also add a remove-then-rename p_rename (FATFS f_rename can't replace
an existing target, no hardlinks), with posix.c's original scoped out in the
component CMakeLists, plus symlink/gai_strerror link stubs git_push pulls in.

p_rename was verified on hardware (cleared the rename error); the utimes fix
is diagnosed from the failure + libgit2 source and build-verified, on-device
confirmation still pending.
2026-07-06 00:18:16 +02:00
Julien Calixte
a0e58e029a fix(firmware): raise main task stack to 96 KB for libgit2 depth
libgit2's repository_init -> config-write -> FATFS -> wear-leveling chain
nests ~10 GIT_PATH_MAX (4 KB) stack buffers deep; a trivial config write
measured ~67 KB on hardware and overflowed the previous 48 KB, corrupting a
newlib lock handle (LoadProhibited in xQueueGenericSend). Shared with the
editor build, so this is temporary -- git should move to a dedicated
large-stack task and this can drop back to ~16 KB.
2026-07-06 00:18:07 +02:00
Julien Calixte
4441618d0f feat(firmware): add flash-FAT storage partition table
16 MB layout adding a `storage` FAT data partition for the on-device git
working copy. Applied only by `just flash-git-push` (espflash
--partition-table); the editor flash keeps its default single-app layout.
2026-07-06 00:18:00 +02:00
9 changed files with 502 additions and 6 deletions

View File

@@ -13,5 +13,13 @@ TW_WIFI_PASS=your-password
# Note: the spike assumes WPA2-Personal. Open network → leave TW_WIFI_PASS empty.
# A WPA3-only AP needs AuthMethod::WPA3Personal in src/bin/wifi_tls.rs.
# Spike 7 (gitoxide push) will add: TW_REMOTE_URL, TW_GH_USER, TW_PAT,
# TW_AUTHOR_NAME, TW_AUTHOR_EMAIL.
# Spike 7 — on-device git push (`just flash-git-push`, src/bin/git_push.rs).
# HTTPS + PAT auth (ADR-005); baked into the git_push image at build time and
# never logged. Point TW_REMOTE_URL at a THROWAWAY test repo — the spike pushes
# a fresh `device/<unix>` branch on every boot.
TW_REMOTE_URL=https://github.com/you/typoena-test.git
TW_GH_USER=your-github-username
TW_PAT=ghp_xxxxxxxxxxxxxxxxxxxx
# Commit author (message is a timestamp).
TW_AUTHOR_NAME=Typoena
TW_AUTHOR_EMAIL=typoena@example.com

View File

@@ -38,6 +38,16 @@ path = "src/bin/git_smoke.rs"
harness = false
required-features = ["git"]
# Spike 7 Path 2 finish — on-device init/commit/push over mbedTLS HTTPS. Wi-Fi +
# SNTP + flash-FAT + libgit2 in one bench binary. Gated behind `git` like
# git_smoke. Build/flash with `just flash-git-push` (needs the git TW_* vars in
# .env; flashes partitions.csv so the `storage` FAT partition exists).
[[bin]]
name = "git_push"
path = "src/bin/git_push.rs"
harness = false
required-features = ["git"]
[profile.release]
opt-level = "s"

View File

@@ -27,7 +27,21 @@ fn main() {
// them in with env!(). Empty when unset: the network spike checks at
// runtime and prints a clear message, so the *editor* build never has to
// carry Wi-Fi creds. Source them from firmware/.env (loaded by `just`).
for var in ["TW_WIFI_SSID", "TW_WIFI_PASS"] {
//
// The TW_REMOTE_URL / TW_GH_USER / TW_PAT / TW_AUTHOR_* vars back Spike 7's
// on-device push (src/bin/git_push.rs). env!() embeds a value only in a
// binary that references it, so the editor binary carries none of these.
// NOTE: TW_PAT ends up in the git_push image — fine for the bench spike, but
// a product must not bake the PAT into flash (ADR-005).
for var in [
"TW_WIFI_SSID",
"TW_WIFI_PASS",
"TW_REMOTE_URL",
"TW_GH_USER",
"TW_PAT",
"TW_AUTHOR_NAME",
"TW_AUTHOR_EMAIL",
] {
let val = std::env::var(var).unwrap_or_default();
println!("cargo:rustc-env={var}={val}");
println!("cargo:rerun-if-env-changed={var}");

View File

@@ -106,3 +106,14 @@ target_compile_options(${COMPONENT_LIB} PRIVATE
-Wno-error=incompatible-pointer-types
-Wno-error=implicit-int
)
# FATFS can't do POSIX rename-replace (f_rename fails if the target exists) and
# has no hardlinks, so libgit2's p_rename (link-then-rename) can't overwrite the
# config/refs/HEAD/index files its lock→commit writes depend on. Compile
# posix.c's p_rename under a throwaway name — scoped to this ONE file so no other
# TU is touched — and provide our own remove-then-rename p_rename in esp_stubs.c
# (p_rename is the single atomic-rename path: filebuf/futils/indexer/refdb_fs).
set_source_files_properties(
"${LG2}/src/util/posix.c"
PROPERTIES COMPILE_DEFINITIONS "p_rename=libgit2_unused_p_rename"
)

View File

@@ -14,6 +14,8 @@
#include <pwd.h>
#include <errno.h>
#include <sys/time.h>
#include <sys/stat.h> /* stat() for the existence-gated utimes() below */
#include <stdio.h> /* remove(), rename() for the p_rename replacement */
/* One implicit root user/group. */
uid_t getuid(void) { return 0; }
@@ -48,10 +50,59 @@ ssize_t readlink(const char *path, char *buf, size_t bufsiz)
return -1;
}
/* VFS has no utimes(); accept and ignore (file mtime is cosmetic here). */
/* No symlinks on FAT: creating one is unsupported. libgit2 calls this only in
* its "does this filesystem support symlinks?" probe (fs_path.c) and while
* copying trees (futils.c) — a clean failure is the honest answer, and libgit2
* treats the target as a normal file. (git_push referenced it; git_smoke, which
* only touched the ODB, did not — hence it surfaces now.) */
int symlink(const char *target, const char *linkpath)
{
(void)target;
(void)linkpath;
errno = ENOSYS;
return -1;
}
/* FATFS/VFS can't set file times — but utimes() MUST still fail for a path that
* doesn't exist. libgit2's git_futils_touch() is p_utimes(), and that is how the
* loose ODB's `freshen` probe answers "does this object already exist?":
* git_odb_write() SKIPS the write entirely when freshen succeeds (odb.c:1629).
* A blanket `return 0` made every freshen succeed, so libgit2 believed every
* object was already on disk and silently dropped ALL loose-object writes —
* blobs/trees/commits never persisted, and write_tree then failed with
* "invalid object specified". Gate success on existence: present → 0 (actually
* setting the time is a cosmetic no-op we skip), absent → -1/ENOENT so freshen
* reports "not found" and the real write proceeds. */
int utimes(const char *path, const struct timeval times[2])
{
(void)path;
struct stat st;
(void)times;
if (stat(path, &st) != 0)
return -1; /* stat set errno (ENOENT for a missing object) */
return 0;
}
/* lwip implements getaddrinfo() but not gai_strerror(); libgit2's socket stream
* (streams/socket.c) uses it only to format a connect-error message, so a
* constant string is sufficient. Deliberately no <netdb.h> include: the symbol
* is undefined at link (no macro/decl shadows it), so a plain definition is
* safe, and the ABI (pointer vs. implicit-int return) matches on xtensa. */
const char *gai_strerror(int ecode)
{
(void)ecode;
return "getaddrinfo failure";
}
/* POSIX rename() atomically replaces an existing target; FATFS f_rename does
* NOT (it fails with EEXIST) and FAT has no hardlinks, so libgit2's own
* p_rename (link-then-rename, in posix.c) can't overwrite config/refs/HEAD/index
* during their lock→commit. Provide replace semantics: drop the target, then
* rename. Not crash-atomic (a crash between the two loses `to`), but FAT offers
* no atomic replace — acceptable for the working copy. posix.c's original is
* compiled as libgit2_unused_p_rename (see the component CMakeLists), so this is
* the p_rename every caller links against. */
int p_rename(const char *from, const char *to)
{
(void)remove(to); /* ignore ENOENT when `to` doesn't exist yet */
return rename(from, to) == 0 ? 0 : -1;
}

View File

@@ -11,6 +11,12 @@ elf := "target/xtensa-esp32s3-espidf/release/firmware"
elf_wifi := "target/xtensa-esp32s3-espidf/release/wifi_tls"
elf_sd := "target/xtensa-esp32s3-espidf/release/sd_fat"
elf_git := "target/xtensa-esp32s3-espidf/release/git_smoke"
elf_git_push := "target/xtensa-esp32s3-espidf/release/git_push"
# Custom partition table (adds the `storage` FAT partition for the git working
# copy). Only the git-push flash applies it — the editor flash keeps the default
# single-app layout, so this change is scoped to Spike 7's finish.
partition_table := justfile_directory() + "/partitions.csv"
# Spike 7 Path 2 — env for the git2/libgit2 build. LIBGIT2_SRC points at the
# vendored libgit2 submodule (v1.9.4, matches libgit2-sys 0.18.5). pkgconfig/
@@ -72,6 +78,20 @@ flash-git:
monitor-git:
espflash monitor --elf {{elf_git}}
# Spike 7 finish — build the on-device git push (Wi-Fi + SNTP + flash-FAT + libgit2)
build-git-push:
{{esp_env}} {{git_env}} cargo build --release --bin git_push --features git
# Spike 7 finish — flash + monitor. espflash applies the custom partition table
# (so the `storage` FAT partition exists) and sets 16 MB flash. Uses espflash
# directly, not `cargo run`, so the table is applied only to this binary.
flash-git-push: build-git-push
espflash flash --monitor --partition-table {{partition_table}} --flash-size 16mb {{elf_git_push}}
# serial monitor for the git push, with decoded backtraces
monitor-git-push:
espflash monitor --elf {{elf_git_push}}
# detect board, print chip/MAC/flash size
info:
espflash board-info

19
firmware/partitions.csv Normal file
View File

@@ -0,0 +1,19 @@
# Typoena partition table — 16 MB flash (ESP32-S3-WROOM-1-N16R8).
#
# Adds a `storage` data partition of subtype `fat` so the on-device git working
# copy (Spike 7) lives on flash-FAT: `esp_vfs_fat_spiflash_mount_rw_wl` finds it
# by the label below and mounts it at /spiflash.
#
# esp-idf-rs does NOT compile this table (it builds `libespidf` as a static lib,
# not a full app project) — espflash applies it at flash time via
# `--partition-table partitions.csv`. Only `just flash-git-push` passes that flag,
# so the editor flash is unaffected and keeps its default single-app layout.
#
# factory is 3 MB — generous headroom for the libgit2-linked binary (the editor
# is ~1 MB; git_push adds Wi-Fi + FAT + libgit2). storage is 4 MB. Total ~7.4 MB.
#
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x6000,
phy_init, data, phy, 0xf000, 0x1000,
factory, app, factory, 0x10000, 0x300000,
storage, data, fat, 0x310000, 0x400000,
1 # Typoena partition table — 16 MB flash (ESP32-S3-WROOM-1-N16R8).
2 #
3 # Adds a `storage` data partition of subtype `fat` so the on-device git working
4 # copy (Spike 7) lives on flash-FAT: `esp_vfs_fat_spiflash_mount_rw_wl` finds it
5 # by the label below and mounts it at /spiflash.
6 #
7 # esp-idf-rs does NOT compile this table (it builds `libespidf` as a static lib,
8 # not a full app project) — espflash applies it at flash time via
9 # `--partition-table partitions.csv`. Only `just flash-git-push` passes that flag,
10 # so the editor flash is unaffected and keeps its default single-app layout.
11 #
12 # factory is 3 MB — generous headroom for the libgit2-linked binary (the editor
13 # is ~1 MB; git_push adds Wi-Fi + FAT + libgit2). storage is 4 MB. Total ~7.4 MB.
14 #
15 # Name, Type, SubType, Offset, Size, Flags
16 nvs, data, nvs, 0x9000, 0x6000,
17 phy_init, data, phy, 0xf000, 0x1000,
18 factory, app, factory, 0x10000, 0x300000,
19 storage, data, fat, 0x310000, 0x400000,

View File

@@ -2,7 +2,19 @@
# You might have to increase this further if you allocate large stack variables in the main task.
# Bumped for the TLS handshake (Spike 6): mbedtls runs on the calling task and
# wants several KB of stack on top of the app's own usage.
CONFIG_ESP_MAIN_TASK_STACK_SIZE=12288
# Bumped hard for Spike 7: libgit2's call chain is stack-hungry. Nearly every
# function puts a `char path[GIT_PATH_MAX]` (4 KB) buffer on the stack, and the
# repository_init → config-write → FATFS → wear-leveling chain nests ~10 of them
# deep — a trivial config write measured ~67 KB of stack on hardware, overflowing
# the previous 48 KB and smashing an adjacent newlib lock handle (LoadProhibited
# in xQueueGenericSend). The push (smart-HTTP + pack + mbedTLS) is deeper still.
# NOTE: this earlier looked like "time() only works on the main task" — but that
# iteration ran git on a std::thread with the default 4 KB stack; the same chain
# just overflowed sooner. It's stack depth, not thread-vs-main.
# CAVEAT: this sdkconfig is shared with the editor build, so the editor also
# reserves this stack for nothing. Once the push is proven, git should move to a
# dedicated large-stack task and this can drop back to ~16 KB.
CONFIG_ESP_MAIN_TASK_STACK_SIZE=98304
# Increase a bit these stack sizes as they are also a bit too small by default
CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096

View File

@@ -0,0 +1,351 @@
//! Spike 7 — Path 2 finish: on-device `init → commit → push` over mbedTLS HTTPS.
//!
//! Gate D proved the `git2` safe API links and runs on device (SHA1 via
//! mbedTLS). This is the real prize: the full publish path the editor's `git`
//! module will run, end to end on hardware —
//!
//! 1. Wi-Fi assoc + SNTP. A valid wall clock is needed twice: mbedTLS checks
//! the server cert's validity window, and the commit signature is stamped
//! with the current time.
//! 2. Mount flash-FAT at /spiflash (partition `storage`, see partitions.csv).
//! The working copy lives here — the ADR-007 storage question is settled
//! *for the spike* as flash-FAT (sidesteps the still-unresolved SD card);
//! SD stays the product plan of record.
//! 3. `git init` a fresh working copy, write a file, stage it, commit with the
//! configured author (message = a timestamp), and push HEAD to a fresh
//! per-boot branch `device/<unix>` on the HTTPS remote, PAT in the
//! credential callback (never logged).
//!
//! Why push to a *fresh* branch and not `add` onto a clone: a fresh `init` each
//! boot has an unrelated history, so pushing onto an existing branch would be a
//! non-fast-forward and drag in merge-unrelated-histories handling. A unique
//! `device/<unix>` branch is always a clean create — it isolates the actual
//! unknown (does the push transport work on device) from history reconciliation.
//! The product will hold a *persistent clone* so real publishes fast-forward;
//! proving clone/fetch on device is a clean follow-up.
//!
//! ## Cert verification — SPIKE SHORTCUT
//!
//! libgit2's mbedTLS stream defaults to `GIT_DEFAULT_CERT_LOCATION = NULL` with
//! `MBEDTLS_SSL_VERIFY_OPTIONAL`, and the http transport treats the resulting
//! `GIT_ECERTIFICATE` as non-fatal, deferring the trust decision to the app's
//! `certificate_check` callback (httpclient.c:834, smart.c:461). So esp-idf's
//! validated cert bundle (Spike 6) is NOT wired into libgit2. Here the callback
//! ACCEPTS the cert with a WARN — enough to prove transport + auth + pack upload.
//! Real trust-store wiring (`GIT_OPT_SET_SSL_CERT_LOCATIONS` → an embedded CA
//! PEM on FAT, or a custom subtransport delegating to esp-idf's bundle) is a
//! separable hardening task and MUST land before this leaves the bench.
//!
//! Build/flash with `just flash-git-push` (needs the git TW_* vars in .env).
use std::cell::RefCell;
use std::ffi::CStr;
use std::fs;
use std::io::Write;
use std::rc::Rc;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use anyhow::{bail, Context, Result};
use esp_idf_svc::eventloop::EspSystemEventLoop;
use esp_idf_svc::hal::delay::FreeRtos;
use esp_idf_svc::hal::peripherals::Peripherals;
use esp_idf_svc::nvs::EspDefaultNvsPartition;
use esp_idf_svc::sntp::{EspSntp, SyncStatus};
use esp_idf_svc::sys::{self, esp};
use esp_idf_svc::wifi::{AuthMethod, BlockingWifi, ClientConfiguration, Configuration, EspWifi};
use git2::{
CertificateCheckStatus, Cred, CredentialType, IndexAddOption, PushOptions, RemoteCallbacks,
Repository, Signature,
};
/// Injected by build.rs so serial output identifies the exact build.
const BUILD_TAG: &str = concat!("build ", env!("BUILD_TIME"), " @", env!("BUILD_GIT"));
// Baked in at build time from firmware/.env (see build.rs). Empty when unset;
// checked at runtime so the editor build never depends on them.
const WIFI_SSID: &str = env!("TW_WIFI_SSID");
const WIFI_PASS: &str = env!("TW_WIFI_PASS");
const REMOTE_URL: &str = env!("TW_REMOTE_URL");
const GH_USER: &str = env!("TW_GH_USER");
const PAT: &str = env!("TW_PAT");
const AUTHOR_NAME: &str = env!("TW_AUTHOR_NAME");
const AUTHOR_EMAIL: &str = env!("TW_AUTHOR_EMAIL");
/// flash-FAT partition (partitions.csv) and its VFS mount point.
const FAT_LABEL: &CStr = c"storage";
const MOUNT: &CStr = c"/spiflash";
const MOUNT_STR: &str = "/spiflash";
/// SNTP first-sync budget (same as Spike 6).
const SNTP_TIMEOUT: Duration = Duration::from_secs(20);
fn main() -> Result<()> {
// Required once before any esp-idf-svc call; some runtime patches only link
// if this symbol is referenced. See esp-idf-template#71.
esp_idf_svc::sys::link_patches();
esp_idf_svc::log::EspLogger::initialize_default();
log::info!("Typoena — Spike 7 Path 2 finish (on-device git push), {BUILD_TAG}");
if let Err(e) = run() {
log::error!("❌ Spike 7 setup failed: {e:?}");
}
// Reached only on a setup error (run() idles forever on the happy path).
loop {
FreeRtos::delay_ms(1000);
}
}
fn run() -> Result<()> {
if WIFI_SSID.is_empty() {
bail!("TW_WIFI_SSID is empty — set the network + git TW_* vars in firmware/.env");
}
if REMOTE_URL.is_empty() || GH_USER.is_empty() || PAT.is_empty() {
bail!("TW_REMOTE_URL / TW_GH_USER / TW_PAT must all be set in firmware/.env");
}
let peripherals = Peripherals::take()?;
let sys_loop = EspSystemEventLoop::take()?;
let nvs = EspDefaultNvsPartition::take()?;
// Wi-Fi is bound here and, on the happy path, NEVER dropped: run() idles at
// the end rather than returning. Dropping EspWifi runs wifi_deinit, whose
// free() asserts if the git work left the heap in a bad state — an earlier
// revision crashed exactly there (tlsf_free in wifi_deinit), which masked
// the git-thread logs. Keeping the radio up surfaces the real result.
let _wifi = {
let mut wifi = BlockingWifi::wrap(
EspWifi::new(peripherals.modem, sys_loop.clone(), Some(nvs))?,
sys_loop,
)?;
connect_wifi(&mut wifi)?;
let ip = wifi.wifi().sta_netif().get_ip_info()?;
log::info!("Wi-Fi up — IP {}", ip.ip);
wifi
};
sync_clock()?;
mount_fat().context("mounting flash-FAT")?;
// Git work runs on the MAIN task, not a spawned thread. libgit2 (via mbedTLS
// cert validation and FATFS timestamping) calls time()/gettimeofday, whose
// newlib lock asserts when taken from a Rust std::thread but works on main
// (Spike 6 ran TLS on main fine). The main stack is sized for it in
// sdkconfig.defaults (CONFIG_ESP_MAIN_TASK_STACK_SIZE). Errors are LOGGED,
// not propagated, so the radio stays up and the monitor shows the result.
match git_publish() {
Ok(summary) => log::info!("✅ Spike 7 complete — {summary}"),
Err(e) => log::error!("❌ git_publish failed: {e:?}"),
}
log::info!("idling with Wi-Fi up — press reset to re-run");
loop {
FreeRtos::delay_ms(1000);
}
}
/// Associate with the configured AP and wait for DHCP. Mirrors Spike 6.
fn connect_wifi(wifi: &mut BlockingWifi<EspWifi<'static>>) -> Result<()> {
let auth_method = if WIFI_PASS.is_empty() {
AuthMethod::None
} else {
AuthMethod::WPA2Personal
};
wifi.set_configuration(&Configuration::Client(ClientConfiguration {
ssid: WIFI_SSID.try_into().ok().context("SSID > 32 bytes")?,
password: WIFI_PASS.try_into().ok().context("password > 64 bytes")?,
auth_method,
..Default::default()
}))?;
wifi.start()?;
log::info!("associating with \"{WIFI_SSID}\"");
wifi.connect().context("Wi-Fi association failed")?;
wifi.wait_netif_up().context("DHCP / netif never came up")?;
Ok(())
}
/// Kick off SNTP and block until first sync. Required before TLS (cert validity)
/// and before committing (signature timestamp). Mirrors Spike 6.
fn sync_clock() -> Result<()> {
let sntp = EspSntp::new_default()?;
log::info!("SNTP started, waiting for first sync…");
let start = Instant::now();
while sntp.get_sync_status() != SyncStatus::Completed {
if start.elapsed() >= SNTP_TIMEOUT {
bail!("SNTP did not sync within {SNTP_TIMEOUT:?} — TLS + commit time would be wrong");
}
FreeRtos::delay_ms(100);
}
let unix = now_unix();
if unix < 1_700_000_000 {
bail!("clock still at {unix} after SNTP — refusing TLS/commit with a bad wall clock");
}
log::info!("clock synced — unix {unix}");
Ok(())
}
/// Mount the flash-FAT `storage` partition at /spiflash, formatting on first
/// boot. Unlike the SD spike (which must never reformat a user's card),
/// `format_if_mount_failed = true` is correct here: `storage` is our own blank
/// partition, and a fresh flash needs an initial FAT.
fn mount_fat() -> Result<()> {
let cfg = sys::esp_vfs_fat_mount_config_t {
format_if_mount_failed: true,
max_files: 16, // libgit2 opens several files at once (index, refs, objects)
allocation_unit_size: 4096,
disk_status_check_enable: false,
use_one_fat: false,
};
// SAFETY: valid C strings + config; the driver fills `wl` on success.
let mut wl: sys::wl_handle_t = 0;
esp!(unsafe {
sys::esp_vfs_fat_spiflash_mount_rw_wl(MOUNT.as_ptr(), FAT_LABEL.as_ptr(), &cfg, &mut wl)
})
.context("esp_vfs_fat_spiflash_mount_rw_wl (is the `storage` partition flashed?)")?;
let (mut total, mut free) = (0u64, 0u64);
// Best-effort usage report.
if unsafe { sys::esp_vfs_fat_info(MOUNT.as_ptr(), &mut total, &mut free) } == sys::ESP_OK {
log::info!(
"flash-FAT mounted at {MOUNT_STR} — {} KiB total, {} KiB free",
total / 1024,
free / 1024
);
} else {
log::info!("flash-FAT mounted at {MOUNT_STR}");
}
Ok(())
}
/// The whole publish (on the main task): init a fresh working copy, write a
/// file, commit, and push to a fresh remote branch. Returns a one-line summary.
fn git_publish() -> Result<String> {
log::info!("git_publish started — free heap {}", free_heap());
let unix = now_unix();
// Fresh working copy per boot in a UNIQUE dir — never deleted. libgit2
// writes loose objects read-only, and FATFS refuses to f_unlink a read-only
// file (→ EACCES), so a wipe-and-reinit strategy can't clean a prior repo.
// Unique dirs sidestep that; the 4 MB partition holds many tiny repos, and
// each boot pushes to its own branch anyway. (Cleanup of old dirs is a
// product concern, not a spike one.)
let repo_dir = format!("{MOUNT_STR}/wc-{unix}");
let repo = Repository::init(&repo_dir).context("git init working copy")?;
log::info!("init OK at {repo_dir} — free heap {}", free_heap());
// One tracked file. Content is disposable; it just makes a non-empty tree.
let path = format!("{repo_dir}/device.md");
let body = format!("# Typoena on-device publish\n\nunix: {unix}\n{BUILD_TAG}\n");
fs::File::create(&path)
.and_then(|mut f| f.write_all(body.as_bytes()))
.context("writing device.md")?;
log::info!("wrote device.md");
// Stage (add --all semantics) and commit with the configured author. Message
// is the timestamp — the product's `git` module will use a proper ISO-8601
// string (desktop spike uses chrono); unix seconds keep this bench binary
// dependency-free.
let mut index = repo.index().context("opening index")?;
index
.add_all(["*"], IndexAddOption::DEFAULT, None)
.context("staging (add --all)")?;
index.write().context("writing index")?;
let tree = repo.find_tree(index.write_tree().context("writing tree")?)?;
log::info!("staged + tree written — free heap {}", free_heap());
let sig = Signature::now(AUTHOR_NAME, AUTHOR_EMAIL).context("building signature")?;
let message = format!("Typoena on-device publish — unix {unix}");
repo.commit(Some("HEAD"), &sig, &sig, &message, &tree, &[])
.context("creating commit")?;
let local = repo
.head()?
.shorthand()
.context("HEAD has no branch shorthand")?
.to_string();
log::info!("committed to {local} — free heap {}", free_heap());
// Point origin at the HTTPS remote and push to a fresh per-boot branch.
let remote_branch = format!("device/{unix}");
let refspec = format!("refs/heads/{local}:refs/heads/{remote_branch}");
repo.remote("origin", REMOTE_URL)
.context("creating origin remote")?;
log::info!("origin set; pushing {refspec} — free heap {}", free_heap());
push(&repo, &refspec).with_context(|| format!("pushing {refspec}"))?;
log::info!(
"push returned — free heap {}, min-ever {}",
free_heap(),
min_free_heap()
);
Ok(format!("pushed {local} → origin/{remote_branch} over mbedTLS HTTPS"))
}
/// Push `refspec` to origin over HTTPS. Binds the PAT credential + the (spike)
/// cert-accept callback, and surfaces a server-side ref rejection as an error.
fn push(repo: &Repository, refspec: &str) -> Result<()> {
let mut remote = repo.find_remote("origin")?;
// Server-side per-ref status arrives via a callback, NOT as a push() error.
// An Rc<RefCell<…>> lets the callback own a handle while we read the result
// after push() returns (the desktop spike uses the same shape).
let rejection: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
let mut cbs = RemoteCallbacks::new();
cbs.credentials(|_url, _user_from_url, allowed| {
// GitHub over HTTPS asks for USER_PASS_PLAINTEXT: the PAT is the
// password. The PAT is handed to libgit2 here and never logged.
if allowed.contains(CredentialType::USER_PASS_PLAINTEXT) {
return Cred::userpass_plaintext(GH_USER, PAT);
}
Err(git2::Error::from_str(
"server did not offer USER_PASS_PLAINTEXT — cannot authenticate with a PAT",
))
});
// SPIKE SHORTCUT — see module docs. libgit2 can't verify the chain (no CA
// wired in), so it asks us; we accept. Real verification is a follow-up.
cbs.certificate_check(|_cert, host| {
log::warn!(
"cert-check BYPASSED for {host} — libgit2 mbedTLS stream has no CA (spike shortcut)"
);
Ok(CertificateCheckStatus::CertificateOk)
});
{
let rejection = rejection.clone();
cbs.push_update_reference(move |refname, status| {
if let Some(msg) = status {
*rejection.borrow_mut() = Some(format!("{refname}: {msg}"));
}
Ok(())
});
}
let mut opts = PushOptions::new();
opts.remote_callbacks(cbs);
remote.push(&[refspec], Some(&mut opts)).context("push transport")?;
if let Some(msg) = rejection.borrow().clone() {
bail!("remote rejected ref: {msg}");
}
log::info!("push accepted by remote");
Ok(())
}
/// Current wall-clock seconds since the Unix epoch (valid after SNTP).
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn free_heap() -> u32 {
unsafe { sys::esp_get_free_heap_size() }
}
fn min_free_heap() -> u32 {
unsafe { sys::esp_get_minimum_free_heap_size() }
}