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.
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
351
firmware/src/bin/git_push.rs
Normal file
351
firmware/src/bin/git_push.rs
Normal 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() }
|
||||
}
|
||||
Reference in New Issue
Block a user