├── owner_key_example ├── crates ├── rustpatcher │ ├── e2e_test │ │ ├── keys │ │ │ ├── owner_key_win │ │ │ ├── owner_key_linux │ │ │ └── owner_key_macos │ │ └── e2e.rs │ ├── src │ │ ├── lib.rs │ │ ├── macho.rs │ │ ├── version.rs │ │ ├── patch.rs │ │ ├── publisher.rs │ │ ├── distributor.rs │ │ ├── patcher.rs │ │ ├── updater.rs │ │ └── embed.rs │ ├── Makefile │ ├── examples │ │ ├── simple.rs │ │ └── platforms.rs │ ├── Cargo.toml │ ├── xtask │ │ └── sign.rs │ └── README.md └── rustpatcher-macros │ ├── Cargo.toml │ └── src │ └── lib.rs ├── Cargo.toml ├── .gitignore ├── LICENSE ├── README.md └── .github └── workflows └── e2e-test.yml /owner_key_example: -------------------------------------------------------------------------------- 1 | 5wrjzq7h54t61sr8izgqdkrjx9tcqey5n6apff5ju5yadoobuj3o -------------------------------------------------------------------------------- /crates/rustpatcher/e2e_test/keys/owner_key_win: -------------------------------------------------------------------------------- 1 | 1swm1b9affb4c59c8yh69wey4axji7fhyjnwhtmnce69m8tspfmy -------------------------------------------------------------------------------- /crates/rustpatcher/e2e_test/keys/owner_key_linux: -------------------------------------------------------------------------------- 1 | 97zzdmnb7p6mt8zsqc74wtqqqzaw39on4jsoiriozdiq3kquteso -------------------------------------------------------------------------------- /crates/rustpatcher/e2e_test/keys/owner_key_macos: -------------------------------------------------------------------------------- 1 | 7xqhfrze8we3dcuywwm74ctbbcnefyer4u6iph4nccontc7k67yy -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = [ 4 | "crates/rustpatcher-macros", 5 | "crates/rustpatcher", 6 | ] -------------------------------------------------------------------------------- /crates/rustpatcher/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod distributor; 2 | mod patch; 3 | mod patcher; 4 | mod publisher; 5 | mod updater; 6 | mod version; 7 | 8 | #[cfg(target_os = "macos")] 9 | pub mod macho; 10 | 11 | #[doc(hidden)] 12 | pub mod embed; 13 | 14 | #[doc(hidden)] 15 | pub use version::Version; 16 | 17 | #[doc(hidden)] 18 | pub use patch::{Patch, PatchInfo}; 19 | 20 | use distributor::Distributor; 21 | use publisher::Publisher; 22 | use updater::Updater; 23 | 24 | use embed::get_owner_pub_key; 25 | 26 | pub use patcher::spawn; 27 | pub use rustpatcher_macros::*; 28 | pub use updater::UpdaterMode; 29 | -------------------------------------------------------------------------------- /crates/rustpatcher-macros/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rustpatcher-macros" 3 | version = "0.2.0" 4 | edition = "2024" 5 | authors = ["Zacharias Boehler "] 6 | description = "p2p patching system" 7 | license = "MIT" 8 | repository = "https://github.com/rustonbsd/rustpatcher" 9 | readme = "../../README.md" 10 | keywords = ["networking"] 11 | categories = ["network-programming"] 12 | 13 | [lib] 14 | proc-macro = true 15 | 16 | [dependencies] 17 | syn = { version = "2.0", default-features=false, features = ["full","parsing","printing","proc-macro"] } 18 | quote = "1.0" 19 | proc-macro2 = "1.0" 20 | ctor = "0.5.0" 21 | -------------------------------------------------------------------------------- /crates/rustpatcher/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build-simple 2 | build-simple: 3 | cargo build --release --example simple 4 | cargo run --bin rustpatcher sign ../../target/release/examples/simple --key-file ./owner_key 5 | 6 | .PHONY: run-simple 7 | run-simple: 8 | ../../target/release/examples/simple 9 | 10 | .PHONY: build-simple-copy 11 | build-simple-copy: 12 | cargo build --release --example simple 13 | cargo run --bin rustpatcher sign ../../target/release/examples/simple --key-file ./owner_key 14 | cp ../../target/release/examples/simple ../../target/release/examples/simple_copy 15 | 16 | .PHONY: run-simple-copy 17 | run-simple-copy: 18 | ../../target/release/examples/simple_copy -------------------------------------------------------------------------------- /crates/rustpatcher/e2e_test/e2e.rs: -------------------------------------------------------------------------------- 1 | #[cfg(target_os = "macos")] 2 | const PUBLIC_KEY: &str = "9mrnh6bhosexei8ciwe1gm7kqitg7y3rbzjbezqbncpg1sk6sq6o"; 3 | #[cfg(target_os = "linux")] 4 | const PUBLIC_KEY: &str = "6qdxs69eg39f1iu79sza56tqbzzgur4gteowp9fa8dwfpakc3ngy"; 5 | #[cfg(target_os = "windows")] 6 | const PUBLIC_KEY: &str = "bhafqhm8k9e7fzab7i7h6gie6oedncwyffautkngqsa9d1ohzuho"; 7 | 8 | #[tokio::main] 9 | #[rustpatcher::public_key(PUBLIC_KEY)] 10 | async fn main() -> anyhow::Result<()> { 11 | rustpatcher::spawn(rustpatcher::UpdaterMode::Now).await?; 12 | println!("{:?}", rustpatcher::Version::current()?); 13 | 14 | tokio::signal::ctrl_c() 15 | .await 16 | .map_err(|e| anyhow::anyhow!(e)) 17 | } 18 | -------------------------------------------------------------------------------- /crates/rustpatcher/examples/simple.rs: -------------------------------------------------------------------------------- 1 | const PUBLIC_KEY: &str = "axegnqus3miex47g1kxf1j7j8spczbc57go7jgpeixq8nxjfz7gy"; 2 | 3 | #[tokio::main] 4 | #[rustpatcher::public_key(PUBLIC_KEY)] 5 | async fn main() -> anyhow::Result<()> { 6 | // Only in --release builds, not intended for debug builds 7 | rustpatcher::spawn(rustpatcher::UpdaterMode::Now).await?; 8 | 9 | println!("my version is {:?}", rustpatcher::Version::current()?); 10 | 11 | #[cfg(not(debug_assertions))] 12 | println!("{:?}", rustpatcher::Patch::from_self()?.info()); 13 | #[cfg(debug_assertions)] 14 | println!("Debug build, skipping Patch::from_self()"); 15 | 16 | tokio::signal::ctrl_c() 17 | .await 18 | .map_err(|e| anyhow::anyhow!(e)) 19 | } 20 | -------------------------------------------------------------------------------- /crates/rustpatcher/examples/platforms.rs: -------------------------------------------------------------------------------- 1 | #[cfg(target_os = "windows")] 2 | const PUBLIC_KEY: &str = "...windows-key..."; 3 | #[cfg(target_os = "linux")] 4 | const PUBLIC_KEY: &str = "...linux-key..."; 5 | #[cfg(target_os = "macos")] 6 | const PUBLIC_KEY: &str = "...macos-key..."; 7 | 8 | #[rustpatcher::public_key(PUBLIC_KEY)] 9 | #[tokio::main] 10 | async fn main() -> anyhow::Result<()> { 11 | tracing_subscriber::fmt() 12 | .with_max_level(tracing::Level::INFO) 13 | .with_thread_ids(true) 14 | .init(); 15 | 16 | #[cfg(not(debug_assertions))] 17 | { 18 | rustpatcher::spawn(rustpatcher::UpdaterMode::At(13, 40)).await?; 19 | 20 | let self_patch = rustpatcher::Patch::from_self()?; 21 | println!("my version {:?} running", self_patch.info().version); 22 | } 23 | 24 | tokio::signal::ctrl_c() 25 | .await 26 | .map_err(|e| anyhow::anyhow!(e)) 27 | } 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 8 | Cargo.lock 9 | 10 | # These are backup files generated by rustfmt 11 | **/*.rs.bk 12 | 13 | # MSVC Windows builds of rustc generate these, which store debugging information 14 | *.pdb 15 | 16 | # RustRover 17 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 18 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 19 | # and can be added to the global gitignore or merged into this file. For a more nuclear 20 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 21 | #.idea/ 22 | 23 | # Added by cargo 24 | 25 | /target 26 | 27 | .patcher/ 28 | .DS_Store 29 | .env 30 | 31 | owner_key* -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 fun with rust y2 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /crates/rustpatcher-macros/src/lib.rs: -------------------------------------------------------------------------------- 1 | use proc_macro::TokenStream; 2 | use quote::quote; 3 | use syn::{Expr, ItemFn, parse_macro_input}; 4 | 5 | #[proc_macro_attribute] 6 | pub fn public_key(args: TokenStream, input: TokenStream) -> TokenStream { 7 | let input_fn = parse_macro_input!(input as ItemFn); 8 | let public_key_expr = parse_macro_input!(args as Expr); 9 | 10 | let expanded = quote! { 11 | const _: () = { 12 | #[::ctor::ctor] 13 | fn __rustpatcher_init_version() { 14 | let __rustpatcher_public_key: &'static str = { 15 | let __cow: ::std::borrow::Cow<'static, str> = 16 | ::std::convert::Into::<::std::borrow::Cow<'static, str>>::into(#public_key_expr); 17 | match __cow { 18 | ::std::borrow::Cow::Borrowed(s) => s, 19 | ::std::borrow::Cow::Owned(s) => ::std::boxed::Box::leak(s.into_boxed_str()), 20 | } 21 | }; 22 | ::rustpatcher::embed::embed(env!("CARGO_PKG_VERSION"), __rustpatcher_public_key); 23 | } 24 | }; 25 | 26 | #input_fn 27 | }; 28 | 29 | TokenStream::from(expanded) 30 | } 31 | -------------------------------------------------------------------------------- /crates/rustpatcher/src/macho.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Result, anyhow}; 2 | use goblin::mach::{Mach, MachO}; 3 | 4 | pub fn exclude_code_signature(data: &[u8]) -> Result> { 5 | let mach = Mach::parse(data)?; 6 | 7 | match mach { 8 | Mach::Binary(macho) => exclude_from_macho(data, &macho), 9 | Mach::Fat(_) => Err(anyhow!("Fat/Universal binaries not supported")), 10 | } 11 | } 12 | 13 | fn exclude_from_macho(data: &[u8], macho: &MachO) -> Result> { 14 | for lc in &macho.load_commands { 15 | if let goblin::mach::load_command::CommandVariant::CodeSignature(cmd) = lc.command { 16 | let offset = cmd.dataoff as usize; 17 | 18 | if offset > data.len() { 19 | return Err(anyhow!( 20 | "Code signature offset out of bounds: offset={}, file_len={}", 21 | offset, 22 | data.len() 23 | )); 24 | } 25 | 26 | // Cut the binary at the signature offset 27 | // this makes the hash independent of signature size changes 28 | // (resigning leeds to a new signature of a different size) 29 | return Ok(data[..offset].to_vec()); 30 | } 31 | } 32 | 33 | // No code signature found, return original 34 | Ok(data.to_vec()) 35 | } 36 | -------------------------------------------------------------------------------- /crates/rustpatcher/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rustpatcher" 3 | version = "0.2.2" 4 | edition = "2024" 5 | description = "distributed patching system for single binary applications" 6 | license = "MIT" 7 | authors = ["Zacharias Boehler "] 8 | repository = "https://github.com/rustonbsd/rustpatcher" 9 | homepage = "https://rustonbsd.github.io/" 10 | readme = "README.md" 11 | keywords = ["networking"] 12 | categories = ["network-programming"] 13 | 14 | [dependencies] 15 | rustpatcher_macros = { path = "../rustpatcher-macros", package = "rustpatcher-macros", version = "0.2.0" } 16 | 17 | ctor = "0.6" 18 | actor-helper = { version = "0.2", features = ["tokio","anyhow"] } 19 | tokio ={ version = "1", features = ["rt-multi-thread","macros","sync"] } 20 | anyhow = "1" 21 | serde = { version = "1", default-features = false, features = ["derive"] } 22 | serde_json = "1" 23 | ed25519-dalek = { version = "3.0.0-pre.1", features = ["serde", "rand_core"] } 24 | sha2 = "0.10" 25 | rand = "0.9" 26 | z32 = "1" 27 | clap = { version = "4", features = ["derive"] } 28 | once_cell = "1" 29 | goblin = "0.10" 30 | tempfile = "3" 31 | nix = { version = "0.30", features = ["process"] } 32 | self-replace = "1" 33 | chrono = { version = "0.4", default-features = false, features = ["std"] } 34 | tracing = "0.1" 35 | tracing-subscriber = { version = "0.3", default-features=false, features = ["std"] } 36 | 37 | iroh = { version = "0.94", default-features = false } 38 | postcard = { version = "1" } 39 | 40 | distributed-topic-tracker = { version="0.2.4", default-features = false } 41 | 42 | [[example]] 43 | name = "platforms" 44 | path = "examples/platforms.rs" 45 | 46 | [[example]] 47 | name = "simple" 48 | path = "examples/simple.rs" 49 | 50 | [[example]] 51 | name = "e2e" 52 | path = "e2e_test/e2e.rs" 53 | 54 | [[bin]] 55 | name = "rustpatcher" 56 | path = "xtask/sign.rs" 57 | -------------------------------------------------------------------------------- /crates/rustpatcher/src/version.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt::Display, str::FromStr}; 2 | 3 | use anyhow::bail; 4 | use serde::{Deserialize, Serialize}; 5 | use tracing::warn; 6 | 7 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 8 | pub struct Version(pub i32, pub i32, pub i32); 9 | 10 | impl Display for Version { 11 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 12 | write!(f, "{}.{}.{}", self.0, self.1, self.2) 13 | } 14 | } 15 | 16 | impl FromStr for Version { 17 | type Err = anyhow::Error; 18 | 19 | fn from_str(s: &str) -> Result { 20 | let parts: Vec<&str> = s.split('.').collect(); 21 | 22 | if parts.len() != 3 { 23 | bail!("wrong version format") 24 | } 25 | 26 | let major = parts[0].parse::()?; 27 | let minor = parts[1].parse::()?; 28 | let patch = parts[2].parse::()?; 29 | 30 | Ok(Version(major, minor, patch)) 31 | } 32 | } 33 | 34 | impl PartialOrd for Version { 35 | fn partial_cmp(&self, other: &Self) -> Option { 36 | Some(self.cmp(other)) 37 | } 38 | } 39 | 40 | impl Ord for Version { 41 | fn cmp(&self, other: &Self) -> std::cmp::Ordering { 42 | let self_sum: i128 = ((i32::MAX as i128).pow(2) * self.0 as i128) 43 | + ((i32::MAX as i128).pow(1) * self.1 as i128) 44 | + self.2 as i128; 45 | let other_sum: i128 = ((i32::MAX as i128).pow(2) * other.0 as i128) 46 | + ((i32::MAX as i128).pow(1) * other.1 as i128) 47 | + other.2 as i128; 48 | self_sum.cmp(&other_sum) 49 | } 50 | 51 | fn max(self, other: Self) -> Self 52 | where 53 | Self: Sized, 54 | { 55 | if other > self { other } else { self } 56 | } 57 | 58 | fn min(self, other: Self) -> Self 59 | where 60 | Self: Sized, 61 | { 62 | if other < self { other } else { self } 63 | } 64 | 65 | fn clamp(self, min: Self, max: Self) -> Self 66 | where 67 | Self: Sized, 68 | { 69 | assert!(min <= max); 70 | if self < min { 71 | min 72 | } else if self > max { 73 | max 74 | } else { 75 | self 76 | } 77 | } 78 | } 79 | 80 | impl Version { 81 | pub fn new(major: i32, minor: i32, patch: i32) -> Self { 82 | Version(major, minor, patch) 83 | } 84 | 85 | pub fn current() -> anyhow::Result { 86 | let v = Version::from_str(crate::embed::get_app_version()); 87 | warn!("Current version: {:?}", v); 88 | v 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /crates/rustpatcher/src/patch.rs: -------------------------------------------------------------------------------- 1 | use crate::Version; 2 | use ed25519_dalek::{Signature, Signer, SigningKey}; 3 | use serde::{Deserialize, Serialize}; 4 | use sha2::Digest; 5 | 6 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 7 | pub struct PatchInfo { 8 | pub version: Version, 9 | pub size: u64, 10 | pub hash: [u8; 32], 11 | pub signature: Signature, 12 | } 13 | 14 | #[derive(Debug, Clone, Serialize, Deserialize)] 15 | pub struct Patch { 16 | info: PatchInfo, 17 | data: Vec, 18 | } 19 | 20 | impl Patch { 21 | pub fn info(&self) -> &PatchInfo { 22 | &self.info 23 | } 24 | 25 | pub fn data(&self) -> &Vec { 26 | &self.data 27 | } 28 | 29 | pub fn verify(&self) -> anyhow::Result<()> { 30 | #[cfg(target_os = "macos")] 31 | let data_stripped: Vec = crate::macho::exclude_code_signature(self.data.as_slice())?; 32 | #[cfg(target_os = "macos")] 33 | let data_stripped = data_stripped.as_slice(); 34 | #[cfg(not(target_os = "macos"))] 35 | let data_stripped = self.data.as_slice(); 36 | 37 | let (data_no_embed, _, _) = crate::embed::cut_embed_section(data_stripped)?; 38 | 39 | let mut data_hasher = sha2::Sha512::new(); 40 | data_hasher.update(&data_no_embed); 41 | let data_hash: [u8; 32] = data_hasher.finalize()[..32].try_into()?; 42 | 43 | let mut sign_hash = sha2::Sha512::new(); 44 | sign_hash.update(self.info.version.to_string()); 45 | sign_hash.update(data_hash); 46 | sign_hash.update((data_no_embed.len() as u64).to_le_bytes()); 47 | let sign_hash = sign_hash.finalize(); 48 | 49 | crate::get_owner_pub_key().verify_strict(&sign_hash, &self.info.signature)?; 50 | 51 | if data_hash != self.info.hash { 52 | anyhow::bail!("data hash mismatch"); 53 | } 54 | 55 | if data_no_embed.len() as u64 != self.info.size { 56 | anyhow::bail!("data size mismatch"); 57 | } 58 | 59 | Ok(()) 60 | } 61 | 62 | pub fn sign(owner_signing_key: SigningKey, data: &[u8]) -> anyhow::Result { 63 | #[cfg(target_os = "macos")] 64 | let data_stripped = crate::macho::exclude_code_signature(data)?; 65 | #[cfg(target_os = "macos")] 66 | let data_stripped = data_stripped.as_slice(); 67 | #[cfg(not(target_os = "macos"))] 68 | let data_stripped = data; 69 | 70 | let (data_no_embed, data_embed, _) = crate::embed::cut_embed_section(data_stripped)?; 71 | let version = crate::embed::get_embedded_version(&data_embed)?; 72 | 73 | let mut data_hasher = sha2::Sha512::new(); 74 | data_hasher.update(data_no_embed.as_slice()); 75 | let data_hash = data_hasher.finalize()[..32].try_into()?; 76 | 77 | let mut sign_hash = sha2::Sha512::new(); 78 | sign_hash.update(version.to_string()); 79 | sign_hash.update(data_hash); 80 | sign_hash.update((data_no_embed.len() as u64).to_le_bytes()); 81 | let sign_hash = sign_hash.finalize(); 82 | let signature = owner_signing_key.sign(&sign_hash); 83 | 84 | Ok(PatchInfo { 85 | version, 86 | size: data_no_embed.len() as u64, 87 | hash: data_hash, 88 | signature, 89 | }) 90 | } 91 | 92 | pub fn from_self() -> anyhow::Result { 93 | let data = std::fs::read(std::env::current_exe()?)?; 94 | let patch_info = crate::embed::get_embedded_patch_info(data.as_slice())?; 95 | 96 | let patch = Self { 97 | info: patch_info, 98 | data, 99 | }; 100 | patch.verify()?; 101 | Ok(patch) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /crates/rustpatcher/src/publisher.rs: -------------------------------------------------------------------------------- 1 | use actor_helper::{Action, Actor, Handle, Receiver, act_ok}; 2 | use distributed_topic_tracker::{RecordPublisher, unix_minute}; 3 | use iroh::EndpointId; 4 | use tracing::{debug, error, warn}; 5 | 6 | use crate::{Patch, PatchInfo, Version}; 7 | 8 | #[derive(Debug, Clone)] 9 | pub struct Publisher { 10 | api: Handle, 11 | } 12 | 13 | #[derive(Debug, Clone)] 14 | pub enum PublisherState { 15 | Publishing, 16 | NewerAvailable, 17 | } 18 | 19 | #[derive(Debug)] 20 | struct PublisherActor { 21 | rx: Receiver>, 22 | state: PublisherState, 23 | 24 | interval: tokio::time::Interval, 25 | self_patch: Patch, 26 | record_publisher: RecordPublisher, 27 | 28 | update_starter: tokio::sync::mpsc::Sender<()>, 29 | } 30 | 31 | impl Publisher { 32 | pub fn new( 33 | record_publisher: RecordPublisher, 34 | update_starter: tokio::sync::mpsc::Sender<()>, 35 | ) -> anyhow::Result { 36 | let self_patch = Patch::from_self()?; 37 | let (api, rx) = Handle::channel(); 38 | tokio::spawn(async move { 39 | let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(55)); 40 | interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); 41 | 42 | let mut actor = PublisherActor { 43 | rx, 44 | state: PublisherState::Publishing, 45 | interval, 46 | self_patch, 47 | record_publisher, 48 | update_starter, 49 | }; 50 | if let Err(e) = actor.run().await { 51 | error!("VersionPublisher actor error: {:?}", e); 52 | } 53 | }); 54 | Ok(Self { api }) 55 | } 56 | 57 | pub async fn get_record_publisher(&self) -> anyhow::Result { 58 | self.api 59 | .call(act_ok!(actor => async move { 60 | actor.record_publisher.clone() 61 | })) 62 | .await 63 | } 64 | } 65 | 66 | impl Actor for PublisherActor { 67 | async fn run(&mut self) -> anyhow::Result<()> { 68 | loop { 69 | tokio::select! { 70 | Ok(action) = self.rx.recv_async() => { 71 | action(self).await 72 | } 73 | _ = self.interval.tick(), if matches!(self.state, PublisherState::Publishing) => { 74 | let now = unix_minute(0); 75 | let mut records = self.record_publisher.get_records(now).await; 76 | records.extend(self.record_publisher.get_records(now-1).await); 77 | 78 | warn!("Checked for records, found {} records", records.len()); 79 | let c_version = Version::current()?; 80 | let newer_patch_infos = records 81 | .iter() 82 | .filter_map(|r| if let Ok(patch_info) = r.content::(){ 83 | if let Ok(endpoint_id) = EndpointId::from_bytes(&r.node_id()) { 84 | warn!("Found patch info: {:?}{:?}", endpoint_id,patch_info); 85 | Some((endpoint_id,patch_info.clone())) 86 | } else { 87 | None 88 | } 89 | } else { 90 | None 91 | }) 92 | .filter(|(_,p)| p.version > c_version) 93 | .collect::>(); 94 | 95 | warn!("Checked for updates, found {} newer versions", newer_patch_infos.len()); 96 | if newer_patch_infos.is_empty() { 97 | let res = self.publish_self(now).await; 98 | debug!("Published self: {:?}", res); 99 | continue; 100 | } 101 | self.state = PublisherState::NewerAvailable; 102 | let _ = self.update_starter.send(()).await; 103 | } 104 | } 105 | } 106 | } 107 | } 108 | 109 | impl PublisherActor { 110 | async fn publish_self(&mut self, unix_minute: u64) -> anyhow::Result<()> { 111 | let record = self 112 | .record_publisher 113 | .new_record(unix_minute, self.self_patch.info().clone())?; 114 | self.record_publisher.publish_record(record).await 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /crates/rustpatcher/src/distributor.rs: -------------------------------------------------------------------------------- 1 | use actor_helper::{Action, Actor, Handle, Receiver, act_ok}; 2 | use distributed_topic_tracker::unix_minute; 3 | use iroh::{ 4 | Endpoint, EndpointId, 5 | endpoint::VarInt, 6 | protocol::{AcceptError, ProtocolHandler}, 7 | }; 8 | use sha2::Digest; 9 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; 10 | use tracing::error; 11 | 12 | use crate::{Patch, PatchInfo}; 13 | 14 | #[derive(Debug, Clone)] 15 | pub struct Distributor { 16 | api: Handle, 17 | } 18 | 19 | #[derive(Debug)] 20 | struct DistributorActor { 21 | rx: Receiver>, 22 | 23 | self_patch_bytes: Vec, 24 | endpoint: Endpoint, 25 | } 26 | 27 | impl Distributor { 28 | pub fn new(endpoint: Endpoint) -> anyhow::Result { 29 | let self_patch_bytes = postcard::to_allocvec(&Patch::from_self()?)?; 30 | let (api, rx) = Handle::channel(); 31 | tokio::spawn(async move { 32 | let mut actor = DistributorActor { 33 | rx, 34 | endpoint, 35 | self_patch_bytes, 36 | }; 37 | if let Err(e) = actor.run().await { 38 | error!("Distributor actor error: {:?}", e); 39 | } 40 | }); 41 | Ok(Self { api }) 42 | } 43 | 44 | #[allow(non_snake_case)] 45 | pub fn ALPN() -> Vec { 46 | format!( 47 | "/rustpatcher/{}/v0", 48 | z32::encode(crate::embed::get_owner_pub_key().as_bytes()) 49 | ) 50 | .into_bytes() 51 | } 52 | 53 | pub async fn get_patch( 54 | &self, 55 | endpoint_id: EndpointId, 56 | patch_info: PatchInfo, 57 | ) -> anyhow::Result { 58 | let endpoint = self 59 | .api 60 | .call(act_ok!(actor => async move { 61 | actor.endpoint.clone() 62 | })) 63 | .await?; 64 | 65 | let conn = endpoint.connect(endpoint_id, &Distributor::ALPN()).await?; 66 | let (mut tx, mut rx) = conn.open_bi().await?; 67 | 68 | // auth: hash(owner_pub_key + unix_minute) 69 | let mut auth_hasher = sha2::Sha512::new(); 70 | auth_hasher.update(crate::embed::get_owner_pub_key().as_bytes()); 71 | auth_hasher.update(unix_minute(0).to_le_bytes()); 72 | let auth_hash = auth_hasher.finalize(); 73 | tx.write_all(&auth_hash).await?; 74 | 75 | if let Ok(0) = rx.read_u8().await { 76 | anyhow::bail!("auth failed"); 77 | } 78 | 79 | // read data 80 | let buf_len = rx.read_u64().await?; 81 | let mut buf = vec![0u8; buf_len as usize]; 82 | rx.read_exact(&mut buf).await?; 83 | 84 | // verify and parse 85 | let patch = postcard::from_bytes::(buf.as_slice())?; 86 | patch.verify()?; 87 | if !patch.info().eq(&patch_info) { 88 | anyhow::bail!("patch info mismatch"); 89 | } 90 | 91 | Ok(patch) 92 | } 93 | } 94 | 95 | impl Actor for DistributorActor { 96 | async fn run(&mut self) -> anyhow::Result<()> { 97 | loop { 98 | tokio::select! { 99 | Ok(action) = self.rx.recv_async() => { 100 | action(self).await 101 | } 102 | } 103 | } 104 | } 105 | } 106 | 107 | type IrohError = Box; 108 | 109 | fn to_iroh_error(e: E) -> AcceptError 110 | where 111 | E: Into, 112 | { 113 | AcceptError::User { source: e.into() } 114 | } 115 | 116 | impl ProtocolHandler for Distributor { 117 | async fn accept( 118 | &self, 119 | connection: iroh::endpoint::Connection, 120 | ) -> Result<(), iroh::protocol::AcceptError> { 121 | let (mut tx, mut rx) = connection.accept_bi().await.map_err(to_iroh_error)?; 122 | 123 | // auth: hash(owner_pub_key + unix_minute) 124 | let mut auth_buf = [0u8; 64]; 125 | rx.read_exact(&mut auth_buf).await.map_err(to_iroh_error)?; 126 | 127 | let owner_pub_key = crate::embed::get_owner_pub_key(); 128 | 129 | fn auth_hash(t: i64, owner_pub_key: &ed25519_dalek::VerifyingKey) -> Vec { 130 | let mut auth_hasher = sha2::Sha512::new(); 131 | auth_hasher.update(owner_pub_key.as_bytes()); 132 | auth_hasher.update(unix_minute(t).to_le_bytes()); 133 | let auth_hash = auth_hasher.finalize(); 134 | auth_hash.to_vec() 135 | } 136 | 137 | let mut accept_auth = false; 138 | for t in -1..2 { 139 | if auth_buf == auth_hash(t, owner_pub_key)[..] { 140 | accept_auth = true; 141 | break; 142 | } 143 | } 144 | 145 | if !accept_auth { 146 | tx.write_u8(0).await.map_err(to_iroh_error)?; 147 | connection.close(VarInt::default(), b"auth failed"); 148 | return Err(to_iroh_error(std::io::Error::other("auth failed"))); 149 | } else { 150 | tx.write_u8(1).await.map_err(to_iroh_error)?; 151 | } 152 | 153 | // send data 154 | let self_patch_bytes = self 155 | .api 156 | .call(act_ok!(actor => async move { 157 | actor.self_patch_bytes.clone() 158 | })) 159 | .await 160 | .map_err(to_iroh_error)?; 161 | 162 | tx.write_u64(self_patch_bytes.len() as u64) 163 | .await 164 | .map_err(to_iroh_error) 165 | .map_err(to_iroh_error)?; 166 | tx.write_all(&self_patch_bytes) 167 | .await 168 | .map_err(to_iroh_error) 169 | .map_err(to_iroh_error)?; 170 | 171 | let _ = tx.stopped().await; 172 | 173 | Ok(()) 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /crates/rustpatcher/src/patcher.rs: -------------------------------------------------------------------------------- 1 | use std::{str::FromStr, sync::Mutex}; 2 | 3 | use actor_helper::{Action, Actor, Handle, Receiver}; 4 | use distributed_topic_tracker::{RecordPublisher, RecordTopic}; 5 | use ed25519_dalek::SigningKey; 6 | use iroh::{Endpoint, protocol::Router}; 7 | use once_cell::sync::OnceCell; 8 | use sha2::Digest; 9 | use tracing::{error, warn}; 10 | 11 | use crate::{Distributor, Publisher, Updater, UpdaterMode}; 12 | 13 | static PATCHER: OnceCell>> = OnceCell::new(); 14 | 15 | pub async fn spawn(update_mode: UpdaterMode) -> anyhow::Result<()> { 16 | #[cfg(not(debug_assertions))] 17 | if PATCHER.get().is_none() { 18 | let patcher = Patcher::builder().updater_mode(update_mode).build().await?; 19 | let _ = PATCHER.set(Mutex::new(Some(patcher))); 20 | } 21 | 22 | #[cfg(debug_assertions)] 23 | if PATCHER.get().is_none() { 24 | let _ = update_mode; 25 | warn!("Skipping rustpatcher initialization in debug build"); 26 | let _ = PATCHER.set(Mutex::new(None)); 27 | } 28 | Ok(()) 29 | } 30 | 31 | #[derive(Debug, Clone)] 32 | pub struct Builder { 33 | updater_mode: UpdaterMode, 34 | } 35 | 36 | impl Default for Builder { 37 | fn default() -> Self { 38 | Self { 39 | updater_mode: UpdaterMode::Now, 40 | } 41 | } 42 | } 43 | 44 | impl Builder { 45 | #[cfg_attr(debug_assertions, allow(dead_code))] 46 | pub fn updater_mode(mut self, mode: UpdaterMode) -> Self { 47 | self.updater_mode = mode; 48 | self 49 | } 50 | 51 | #[cfg_attr(debug_assertions, allow(dead_code))] 52 | pub async fn build(self) -> anyhow::Result { 53 | let secret_key = iroh::SecretKey::generate(&mut rand::rng()); 54 | let signing_key = SigningKey::from_bytes(&secret_key.to_bytes()); 55 | 56 | let topic_id = RecordTopic::from_str( 57 | format!( 58 | "rustpatcher:{}", 59 | z32::encode(crate::embed::get_owner_pub_key().as_bytes()) 60 | ) 61 | .as_str(), 62 | )?; 63 | let mut hash = sha2::Sha512::new(); 64 | hash.update(topic_id.hash()); 65 | hash.update("v1"); 66 | let initial_secret = hash.finalize().to_vec(); 67 | 68 | let record_publisher = RecordPublisher::new( 69 | topic_id, 70 | signing_key.verifying_key(), 71 | signing_key, 72 | None, 73 | initial_secret, 74 | ); 75 | 76 | let (update_starter, update_receiver) = tokio::sync::mpsc::channel(1); 77 | let publisher = Publisher::new(record_publisher, update_starter)?; 78 | 79 | let endpoint = Endpoint::builder() 80 | .secret_key(secret_key.clone()) 81 | .bind() 82 | .await?; 83 | 84 | let distributor = Distributor::new(endpoint.clone())?; 85 | 86 | let _router = iroh::protocol::Router::builder(endpoint.clone()) 87 | .accept(Distributor::ALPN(), distributor.clone()) 88 | .spawn(); 89 | 90 | Ok(Patcher::new( 91 | publisher, 92 | self.updater_mode, 93 | update_receiver, 94 | distributor, 95 | endpoint, 96 | _router, 97 | )) 98 | } 99 | } 100 | 101 | #[derive(Debug, Clone)] 102 | pub struct Patcher { 103 | _api: Handle, 104 | } 105 | 106 | #[derive(Debug)] 107 | struct PatcherActor { 108 | rx: Receiver>, 109 | 110 | publisher: Publisher, 111 | updater: Option, 112 | updater_mode: UpdaterMode, 113 | distributor: Distributor, 114 | 115 | _endpoint: Endpoint, 116 | _router: Router, 117 | 118 | update_receiver: tokio::sync::mpsc::Receiver<()>, 119 | } 120 | 121 | impl Patcher { 122 | #[cfg_attr(debug_assertions, allow(dead_code))] 123 | pub fn builder() -> Builder { 124 | Builder::default() 125 | } 126 | 127 | fn new( 128 | publisher: Publisher, 129 | updater_mode: UpdaterMode, 130 | update_receiver: tokio::sync::mpsc::Receiver<()>, 131 | distributor: Distributor, 132 | endpoint: Endpoint, 133 | router: Router, 134 | ) -> Self { 135 | let (api, rx) = Handle::channel(); 136 | tokio::spawn(async move { 137 | let mut actor = PatcherActor { 138 | rx, 139 | publisher, 140 | updater: None, 141 | _endpoint: endpoint, 142 | _router: router, 143 | updater_mode, 144 | update_receiver, 145 | distributor, 146 | }; 147 | if let Err(e) = actor.run().await { 148 | error!("Patcher actor error: {:?}", e); 149 | } 150 | }); 151 | 152 | Self { _api: api } 153 | } 154 | } 155 | 156 | impl Actor for PatcherActor { 157 | async fn run(&mut self) -> anyhow::Result<()> { 158 | loop { 159 | tokio::select! { 160 | Ok(action) = self.rx.recv_async() => { 161 | action(self).await 162 | } 163 | Some(_) = self.update_receiver.recv(), if self.updater.is_none() => { 164 | warn!("update notification received from Publisher, starting Updater"); 165 | if let Ok(record_publisher) = self.publisher.get_record_publisher().await { 166 | self.updater = Some(Updater::new(self.updater_mode.clone(),self.distributor.clone(),record_publisher)); 167 | } else { 168 | anyhow::bail!("Failed to get RecordPublisher for Updater"); 169 | } 170 | } 171 | } 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /crates/rustpatcher/xtask/sign.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs::{self, OpenOptions}, 3 | io::{Seek, SeekFrom, Write}, 4 | path::PathBuf, 5 | }; 6 | 7 | use clap::{Parser, Subcommand}; 8 | use ed25519_dalek::SigningKey; 9 | 10 | #[derive(Parser, Debug)] 11 | #[command(name = "rustpatcher", version, about)] 12 | struct RootCli { 13 | #[command(subcommand)] 14 | cmd: Commands, 15 | } 16 | enum KeySource { 17 | File(PathBuf), 18 | Inline(String), 19 | } 20 | 21 | #[derive(Subcommand, Debug)] 22 | enum Commands { 23 | /// Sign and embed a patch into a binary 24 | Sign(SignArgs), 25 | /// generates new signing key and saves to file it prints pubkey to std out 26 | Gen { 27 | #[arg(value_name = "PATH", required = true)] 28 | key_file: std::path::PathBuf, 29 | }, 30 | } 31 | 32 | #[derive(Parser, Debug)] 33 | struct SignArgs { 34 | #[arg(value_name = "BIN")] 35 | binary: std::path::PathBuf, 36 | #[arg(long = "key-file", value_name = "PATH")] 37 | key_file: Option, 38 | #[arg(long = "key", value_name = "Z32")] 39 | key: Option, 40 | } 41 | 42 | fn main() -> anyhow::Result<()> { 43 | let root = RootCli::parse(); 44 | match root.cmd { 45 | Commands::Sign(args) => sign_cmd(args), 46 | Commands::Gen { key_file } => generate_key_cmd(key_file), 47 | } 48 | } 49 | 50 | fn generate_key_cmd(key_file: std::path::PathBuf) -> anyhow::Result<()> { 51 | let signing_key = SigningKey::generate(&mut rand::rng()); 52 | let signing_key_z32 = z32::encode(signing_key.as_bytes()); 53 | let signing_key_bytes = signing_key_z32.as_bytes(); 54 | 55 | if key_file.exists() { 56 | println!("Key file {} already exists", key_file.display()); 57 | return Ok(()); 58 | } 59 | 60 | fs::write(&key_file, signing_key_bytes)?; 61 | println!("Wrote signing key to {}", key_file.display()); 62 | println!( 63 | "Public key (z-base-32): {}", 64 | z32::encode(signing_key.verifying_key().as_bytes()) 65 | ); 66 | println!("\n"); 67 | println!("// Add the following to your main function:\n"); 68 | println!( 69 | "#[rustpatcher::public_key(\"{}\")]", 70 | z32::encode(signing_key.verifying_key().as_bytes()) 71 | ); 72 | println!("fn main() {{\n // your code here\n}}"); 73 | 74 | Ok(()) 75 | } 76 | 77 | fn sign_cmd(args: SignArgs) -> anyhow::Result<()> { 78 | let key_src = if let Some(k) = args.key { 79 | KeySource::Inline(k) 80 | } else { 81 | KeySource::File( 82 | args.key_file 83 | .unwrap_or_else(|| PathBuf::from("./owner_signing_key")), 84 | ) 85 | }; 86 | 87 | let signing_key = load_signing_key(key_src)?; 88 | 89 | // sign with codesign tool to keep the signature length etc consistent. 90 | // the code signature might change in length from ld64 and codesign (at least thats my theory right now) 91 | // if we first refresh the signature with the codesign tool, then compute our signautre (-codesign block at end data[..offset]) 92 | // this still leaves us with the signature dependent LC_CODE_SIGNATURE header but it only holds offset and size 93 | // wich are constant when using the identical signature method (codesign instead of ld64 in this case). 94 | // we compute our signature over the data without the codesign block (data[..offset]) 95 | // then we sign again with codesign after embedding our patch info. 96 | #[cfg(target_os = "macos")] 97 | macos_codesign(&args.binary)?; 98 | 99 | let mut file = OpenOptions::new() 100 | .read(true) 101 | .write(true) 102 | .create(false) 103 | .open(&args.binary)?; 104 | 105 | let mut data = fs::read(&args.binary) 106 | .map_err(|e| anyhow::anyhow!("failed to read binary {}: {}", args.binary.display(), e))?; 107 | 108 | let patch_info = rustpatcher::Patch::sign(signing_key, data.as_slice())?; 109 | let (_, _, embed_region) = rustpatcher::embed::cut_embed_section(data.as_slice())?; 110 | rustpatcher::embed::set_embedded_patch_info(&mut data, patch_info, embed_region)?; 111 | 112 | file.seek(SeekFrom::Start(0))?; 113 | file.write_all(&data)?; 114 | file.set_len(data.len() as u64)?; 115 | drop(file); 116 | 117 | // post sign (see comment above) 118 | #[cfg(target_os = "macos")] 119 | macos_codesign(&args.binary)?; 120 | 121 | Ok(()) 122 | } 123 | 124 | #[cfg(target_os = "macos")] 125 | fn macos_codesign(binary: &PathBuf) -> anyhow::Result<()> { 126 | // re-sign the binary with codesign 127 | let status = std::process::Command::new("codesign") 128 | .arg("--force") 129 | .arg("--sign") 130 | .arg("-") 131 | .arg(binary) 132 | .stdout(std::process::Stdio::null()) 133 | .stderr(std::process::Stdio::null()) 134 | .status()?; 135 | if !status.success() { 136 | return Err(anyhow::anyhow!("mac os specific codesign failed")); 137 | } 138 | Ok(()) 139 | } 140 | 141 | fn load_signing_key(source: KeySource) -> anyhow::Result { 142 | match source { 143 | KeySource::File(path) => { 144 | let data = if let Ok(data) = fs::read(&path) { 145 | data 146 | } else { 147 | let signing_key = SigningKey::generate(&mut rand::rng()); 148 | let signing_key_z32 = z32::encode(signing_key.as_bytes()); 149 | let signing_key_bytes = signing_key_z32.as_bytes(); 150 | fs::write(&path, signing_key_bytes)?; 151 | signing_key_bytes.to_vec() 152 | }; 153 | 154 | let sing_key_bytes = z32::decode(&data) 155 | .map_err(|_| anyhow::anyhow!("failed to decode signing key from z-base-32"))?; 156 | let sign_key_bytes = sing_key_bytes.as_slice(); 157 | Ok(SigningKey::from_bytes(sign_key_bytes.try_into().map_err( 158 | |_| { 159 | anyhow::anyhow!( 160 | "signing key must be 32 bytes (got {})", 161 | sign_key_bytes.len() 162 | ) 163 | }, 164 | )?)) 165 | } 166 | KeySource::Inline(key_str) => { 167 | let sing_key_bytes = z32::decode(key_str.as_bytes()) 168 | .map_err(|_| anyhow::anyhow!("failed to decode signing key from z-base-32"))?; 169 | let sign_key_bytes = sing_key_bytes.as_slice(); 170 | Ok(SigningKey::from_bytes(sign_key_bytes.try_into().map_err( 171 | |_| { 172 | anyhow::anyhow!( 173 | "signing key must be 32 bytes (got {})", 174 | sign_key_bytes.len() 175 | ) 176 | }, 177 | )?)) 178 | } 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /crates/rustpatcher/src/updater.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | env, 3 | ffi::{CString, OsString}, 4 | io::Write, 5 | process, ptr, 6 | }; 7 | 8 | use actor_helper::{Action, Actor, Handle, Receiver}; 9 | use chrono::Timelike; 10 | use distributed_topic_tracker::{RecordPublisher, unix_minute}; 11 | use iroh::EndpointId; 12 | use nix::libc; 13 | use tracing::{error, info}; 14 | 15 | use crate::{Patch, PatchInfo, Version, distributor::Distributor}; 16 | 17 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 18 | pub enum UpdaterMode { 19 | Now, 20 | OnRestart, 21 | At(u8, u8), // hour, minute 22 | } 23 | 24 | #[derive(Debug, Clone)] 25 | pub struct Updater { 26 | _api: Handle, 27 | } 28 | 29 | #[derive(Debug)] 30 | struct UpdaterActor { 31 | rx: Receiver>, 32 | distributor: Distributor, 33 | 34 | mode: UpdaterMode, 35 | newer_patch: Option, 36 | record_publisher: RecordPublisher, 37 | try_update_interval: tokio::time::Interval, 38 | 39 | self_path_before_replace: Option, 40 | } 41 | 42 | impl Updater { 43 | pub fn new( 44 | mode: UpdaterMode, 45 | distributor: Distributor, 46 | record_publisher: RecordPublisher, 47 | ) -> Self { 48 | let (api, rx) = Handle::channel(); 49 | tokio::spawn(async move { 50 | let mut try_update_interval = 51 | tokio::time::interval(tokio::time::Duration::from_secs(56)); 52 | try_update_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); 53 | 54 | let mut actor = UpdaterActor { 55 | rx, 56 | mode, 57 | distributor, 58 | newer_patch: None, 59 | record_publisher, 60 | try_update_interval, 61 | self_path_before_replace: None, 62 | }; 63 | if let Err(e) = actor.run().await { 64 | error!("Updater actor error: {:?}", e); 65 | } 66 | }); 67 | Self { _api: api } 68 | } 69 | } 70 | 71 | impl Actor for UpdaterActor { 72 | async fn run(&mut self) -> anyhow::Result<()> { 73 | loop { 74 | tokio::select! { 75 | Ok(action) = self.rx.recv_async() => { 76 | action(self).await 77 | } 78 | _ = self.try_update_interval.tick() => { 79 | if self.newer_patch.is_none() { 80 | let patches = self.check_for_updates().await?; 81 | for (node_id, patch_info) in patches { 82 | if self.try_download_patch(node_id, patch_info).await.is_ok() { 83 | break; 84 | } 85 | } 86 | } else { 87 | match self.mode { 88 | UpdaterMode::Now => { 89 | self.restart_after_update().await?; 90 | }, 91 | UpdaterMode::OnRestart => { 92 | // do nothing, wait for next restart 93 | }, 94 | UpdaterMode::At(hour, minute) => { 95 | let now = chrono::Local::now(); 96 | // prob midnight rollover bug here, fine for now [!] todo 97 | let t_offset = (now.hour() as i32 * 60 + now.minute() as i32) - (hour as i32 * 60 + minute as i32); 98 | if matches!(t_offset, 0..2) { 99 | let _ = self.restart_after_update().await; 100 | } 101 | } 102 | } 103 | } 104 | } 105 | } 106 | } 107 | } 108 | } 109 | 110 | impl UpdaterActor { 111 | async fn check_for_updates(&mut self) -> anyhow::Result> { 112 | let now = unix_minute(0); 113 | let mut records = self.record_publisher.get_records(now).await; 114 | records.extend(self.record_publisher.get_records(now - 1).await); 115 | let c_version = Version::current()?; 116 | let mut newer_patch_infos = records 117 | .iter() 118 | .filter_map(|r| { 119 | if let Ok(patch_info) = r.content::() { 120 | if let Ok(endpoint_id) = EndpointId::from_bytes(&r.node_id()) { 121 | Some((endpoint_id, patch_info.clone())) 122 | } else { 123 | None 124 | } 125 | } else { 126 | None 127 | } 128 | }) 129 | .filter(|(_, p)| p.version > c_version) 130 | .collect::>(); 131 | 132 | if newer_patch_infos.is_empty() { 133 | return Ok(vec![]); 134 | } 135 | newer_patch_infos.sort_by_key(|(_, p)| p.version.clone()); 136 | newer_patch_infos.reverse(); 137 | 138 | let newest = newer_patch_infos[0].clone(); 139 | Ok(newer_patch_infos 140 | .iter() 141 | .filter(|(_, p)| p.version == newest.1.version) 142 | .cloned() 143 | .collect::>()) 144 | } 145 | 146 | async fn try_download_patch( 147 | &mut self, 148 | endpoint_id: EndpointId, 149 | patch_info: PatchInfo, 150 | ) -> anyhow::Result<()> { 151 | info!("Downloading patch {:?} from {:?}", patch_info, endpoint_id); 152 | let res = self.distributor.get_patch(endpoint_id, patch_info).await; 153 | info!("Downloaded patch: {:?}", res.is_ok()); 154 | let patch = res?; 155 | self.newer_patch = Some(patch.clone()); 156 | 157 | self.self_path_before_replace = Some(env::current_exe()?.into()); 158 | 159 | let mut temp_file = tempfile::NamedTempFile::new()?; 160 | temp_file.write_all(patch.data())?; 161 | let path = temp_file.path(); 162 | 163 | self_replace::self_replace(path)?; 164 | info!("Updated successfully to version {:?}", patch.info().version); 165 | 166 | if self.mode == UpdaterMode::Now { 167 | self.restart_after_update().await?; 168 | } 169 | Ok(()) 170 | } 171 | 172 | async fn restart_after_update(&mut self) -> anyhow::Result<()> { 173 | let exe_raw = self 174 | .self_path_before_replace 175 | .clone() 176 | .ok_or(anyhow::anyhow!("no self path stored"))?; 177 | let exe = CString::new(exe_raw.to_str().unwrap())?; 178 | 179 | // The array must be null-terminated. 180 | let args: [*const libc::c_char; 1] = [ptr::null()]; 181 | 182 | unsafe { 183 | let res = nix::libc::execv(exe.as_ptr(), args.as_ptr()); 184 | if res != 0 { 185 | let err = std::io::Error::last_os_error(); 186 | error!("execv failed: {:?}", err); 187 | return Err(anyhow::anyhow!("execv failed: {:?}", err)); 188 | } 189 | process::exit(0); 190 | } 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Crates.io](https://img.shields.io/crates/v/rustpatcher.svg)](https://crates.io/crates/rustpatcher) 2 | [![Docs.rs](https://docs.rs/rustpatcher/badge.svg)](https://docs.rs/rustpatcher) 3 | ![License](https://img.shields.io/badge/License-MIT-green) 4 | 5 | # Rust Patcher 6 | Secure fully decentralized software updates. 7 | 8 | ## Supported Platforms 9 | 10 | | Platform | Architecture | Supported | 11 | |----------|--------------|-----------| 12 | | Linux | x86_64 | Yes | 13 | | Linux | ARM64 | Yes | 14 | | macOS | x86_64 | Yes | 15 | | macOS | ARM64 | Yes | 16 | | Windows | - | Not yet | 17 | 18 | **Note:** windows support will follow, *windows build err: libc is not available in nix pkg* 19 | 20 | ## Implementation Flow 21 | 22 | ### 1. Add dependency 23 | ```toml 24 | # Cargo.toml 25 | [dependencies] 26 | rustpatcher = "0.2" 27 | rustpatcher-macros = "0.2" 28 | tokio = { version = "1", features = ["rt-multi-thread","macros"] } 29 | ``` 30 | 31 | ### 2. Embed owner public key and start the updater 32 | ```rust 33 | 34 | #[tokio::main] 35 | #[rustpatcher::public_key("axegnqus3miex47g1kxf1j7j8spczbc57go7jgpeixq8nxjfz7gy")] 36 | async fn main() -> anyhow::Result<()> { 37 | 38 | // Only in --release builds, not intended for debug builds 39 | rustpatcher::spawn(rustpatcher::UpdaterMode::At(13, 40)).await?; 40 | 41 | println!("my version is {:?}", rustpatcher::Version::current()?); 42 | 43 | // your app code after this 44 | tokio::select! { 45 | _ = tokio::signal::ctrl_c() => { 46 | println!("Exiting on Ctrl-C"); 47 | } 48 | } 49 | Ok(()) 50 | } 51 | ``` 52 | 53 | ### 3. Generate signing key (one-time) 54 | ```bash 55 | cargo install rustpatcher 56 | rustpatcher gen ./owner_key 57 | ``` 58 | Output includes: 59 | - Owner signing key saved to ./owner_key (z-base-32 encoded) 60 | - Owner public key (z-base-32) 61 | - Attribute snippet to paste into main: #[rustpatcher::public_key("")] 62 | 63 | ### 4. Build and sign releases 64 | ```bash 65 | # build your binary 66 | cargo build --release 67 | 68 | # sign the compiled binary in-place 69 | rustpatcher sign target/release/ --key-file=./owner_key 70 | ``` 71 | 72 | ### 5. Publish updates 73 | - Run the newly signed binary on at least one node until a couple of peers have updated themselfs. 74 | - The running process periodically publishes the latest PatchInfo to the DHT. 75 | - Clients discover new PatchInfo, fetch the patch from peers, verify, and self-replace. 76 | 77 | 78 | --- 79 | 80 | ## Run Example: simple 81 | ```sh 82 | git clone https://github.com/rustonbsd/rustpatcher 83 | cd rustpatcher 84 | cargo build --release --example simple 85 | cargo run --bin rustpatcher sign target/release/examples/simple --key-file ./owner_key_example 86 | 87 | # Run signed app: 88 | ./target/release/examples/simple 89 | 90 | 91 | # if you increase the version in /crates/rustpatcher/Cargo.toml 92 | # and build+sign+start another node, then the first 93 | # node will update via the second node. 94 | ``` 95 | 96 | --- 97 | 98 | ## Network Architecture 99 | 100 | ```mermaid 101 | sequenceDiagram 102 | participant Owner as Owner Node (new version) 103 | participant DTT as DHT Topic Tracker 104 | participant Peer as Peer Node (old) 105 | 106 | Owner->>DTT: Publish PatchInfo(version, size, hash, sig) 107 | Peer->>DTT: Query latest PatchInfo (minute slots) 108 | DTT-->>Peer: Return newest records 109 | Peer->>Owner: Iroh connect (ALPN /rustpatcher//v0) 110 | Peer->>Owner: Auth = sha512(pubkey || unix_minute) 111 | Owner-->>Peer: OK + Patch (postcard) 112 | Peer->>Peer: Verify(hash, size, ed25519(pubkey)) 113 | Peer->>Peer: Atomic replace + optional execv restart 114 | ``` 115 | 116 | - Discovery: [distributed-topic-tracker](https://github.com/rustonbsd/distributed-topic-tracker) minute-slotted records over the DHT 117 | - Transport: [iroh](https://github.com/n0-computer/iroh) QUIC, ALPN namespaced per owner key 118 | - Authentication: rotating hash auth per minute bucket 119 | 120 | --- 121 | 122 | ## Key Processes 123 | 124 | 1. Version propagation 125 | - Running a node publishes a PatchInfo record roughly every minute. 126 | - Records are minute scoped with short TTL to avoid staleness. 127 | - Peers scan current and previous minute for latest version. 128 | 129 | 2. Patch fetch + verification 130 | - Peer connects to other peers with newer version via iroh using an ALPN derived from the owner pubkey. 131 | - Auth: sha512(owner_pub_key || unix_minute(t)) for t ∈ {-1..1}. 132 | - Owner sends the signed patch (postcard-encoded). 133 | 134 | 3. Self-update mechanism 135 | - Write to temp file 136 | - Atomic [self-replace](https://crates.io/crates/self-replace) 137 | - Optional immediate restart via execv (UpdaterMode::Now) or deferred (OnRestart / At(hh, mm)) 138 | 139 | --- 140 | 141 | ## Data Embedded in the Binary 142 | 143 | - Fixed-size embedded region in a dedicated link section (.embedded_signature) 144 | - Layout: 145 | - 28 bytes: bounds start marker 146 | - 32 bytes: binary hash (sha512 truncated to 32) 147 | - 8 bytes: binary size (LE) 148 | - 64 bytes: ed25519 signature 149 | - 16 bytes: ASCII version (padded) 150 | - 28 bytes: bounds end marker 151 | 152 | At runtime, the library: 153 | - Locates the embedded region 154 | - Parses version/hash/size/signature 155 | - Verifies the binary contents against the signed metadata 156 | 157 | --- 158 | 159 | ## CLI Reference (rustpatcher) 160 | 161 | - gen 162 | - Generates a new ed25519 signing key in z-base-32; prints the public key and attribute snippet. 163 | - sign --key-file 164 | - Reads the compiled binary, computes PatchInfo, and writes it into the embedded region. 165 | 166 | DO NOT COMMIT YOUR PRIVATE KEY! 167 | 168 | ```sh 169 | # add this to your .gitignore 170 | owner_key* 171 | ``` 172 | 173 | Notes: 174 | - Keys are z-base-32 encoded on disk, the public key is embedded in code via #[rustpatcher::public_key("...")]. 175 | - Signing must be re-run after each new build that is intendet to self update. 176 | - For every build target a seperate keypair is required (we don't want the arm users patching in x86 binaries). 177 | 178 | --- 179 | 180 | ## Library API (overview) 181 | 182 | - #[rustpatcher::public_key("")] 183 | - Embeds the owner public key and the package version for verification 184 | - rustpatcher::spawn(mode: UpdaterMode) -> Future> 185 | - Starts discovery, publishing, distribution server, and updater 186 | - UpdaterMode::{Now, OnRestart, At(h, m)} 187 | 188 | --- 189 | 190 | ## How It Changed (vs previous rustpatcher) 191 | 192 | - Single embedded region with explicit bounds, constant size, and zero-allocation compile-time construction 193 | - Signature scheme clarified and minimal: 194 | - sha512(data_no_embed) -> first 32 bytes as hash 195 | - sign sha512(version || hash || size_le) with ed25519 196 | - Owner key embedding via attribute macro, version captured from CARGO_PKG_VERSION and embedded as fixed-length ASCII 197 | - Minute-slotted record publishing and discovery via distributed-topic-tracker 198 | - iroh-based distributor with rotating minute auth derived from owner public key 199 | - Simple updater modes: Now, OnRestart, At(hh:mm) 200 | - CLI split: cargo install rustpatcher to manage keys and sign releases 201 | 202 | --- 203 | 204 | ## Example 205 | 206 | ```rust 207 | use rustpatcher::UpdaterMode; 208 | 209 | #[tokio::main] 210 | #[rustpatcher::public_key("axegnqus3miex47g1kxf1j7j8spczbc57go7jgpeixq8nxjfz7gy")] 211 | async fn main() -> anyhow::Result<()> { 212 | 213 | rustpatcher::spawn(UpdaterMode::At(02, 30)).await?; 214 | 215 | // app code... 216 | Ok(()) 217 | } 218 | ``` 219 | 220 | ## Release Workflow 221 | 222 | 1) Generate key (once): 223 | - rustpatcher gen ./owner_key 224 | 225 | 2) Build + sign each release: 226 | - cargo build --release 227 | - rustpatcher sign target/release/ --key-file=./owner_key 228 | 229 | 3) Deploy and run the signed binary on at least one node: 230 | - It will publish PatchInfo and serve patches to peers. 231 | - No need to have any exposed ports. 232 | 233 | ```make 234 | build: 235 | cargo build --release 236 | rustpatcher sign target/release/ --key-file ./owner_key 237 | 238 | publish: 239 | target/release/ 240 | ``` -------------------------------------------------------------------------------- /crates/rustpatcher/README.md: -------------------------------------------------------------------------------- 1 | [![Crates.io](https://img.shields.io/crates/v/rustpatcher.svg)](https://crates.io/crates/rustpatcher) 2 | [![Docs.rs](https://docs.rs/rustpatcher/badge.svg)](https://docs.rs/rustpatcher) 3 | ![License](https://img.shields.io/badge/License-MIT-green) 4 | 5 | # Rust Patcher 6 | Secure fully decentralized software updates. 7 | 8 | ## Supported Platforms 9 | 10 | | Platform | Architecture | Supported | 11 | |----------|--------------|-----------| 12 | | Linux | x86_64 | Yes | 13 | | Linux | ARM64 | Yes | 14 | | macOS | x86_64 | Yes | 15 | | macOS | ARM64 | Yes | 16 | | Windows | - | Not yet | 17 | 18 | **Note:** windows support will follow, *windows build err: libc is not available in nix pkg* 19 | 20 | ## Implementation Flow 21 | 22 | ### 1. Add dependency 23 | ```toml 24 | # Cargo.toml 25 | [dependencies] 26 | rustpatcher = "0.2" 27 | rustpatcher-macros = "0.2" 28 | tokio = { version = "1", features = ["rt-multi-thread","macros"] } 29 | ``` 30 | 31 | ### 2. Embed owner public key and start the updater 32 | ```rust 33 | 34 | #[tokio::main] 35 | #[rustpatcher::public_key("axegnqus3miex47g1kxf1j7j8spczbc57go7jgpeixq8nxjfz7gy")] 36 | async fn main() -> anyhow::Result<()> { 37 | 38 | // Only in --release builds, not intended for debug builds 39 | rustpatcher::spawn(rustpatcher::UpdaterMode::At(13, 40)).await?; 40 | 41 | println!("my version is {:?}", rustpatcher::Version::current()?); 42 | 43 | // your app code after this 44 | tokio::select! { 45 | _ = tokio::signal::ctrl_c() => { 46 | println!("Exiting on Ctrl-C"); 47 | } 48 | } 49 | Ok(()) 50 | } 51 | ``` 52 | 53 | ### 3. Generate signing key (one-time) 54 | ```bash 55 | cargo install rustpatcher 56 | rustpatcher gen ./owner_key 57 | ``` 58 | Output includes: 59 | - Owner signing key saved to ./owner_key (z-base-32 encoded) 60 | - Owner public key (z-base-32) 61 | - Attribute snippet to paste into main: #[rustpatcher::public_key("")] 62 | 63 | ### 4. Build and sign releases 64 | ```bash 65 | # build your binary 66 | cargo build --release 67 | 68 | # sign the compiled binary in-place 69 | rustpatcher sign target/release/ --key-file=./owner_key 70 | ``` 71 | 72 | ### 5. Publish updates 73 | - Run the newly signed binary on at least one node until a couple of peers have updated themselfs. 74 | - The running process periodically publishes the latest PatchInfo to the DHT. 75 | - Clients discover new PatchInfo, fetch the patch from peers, verify, and self-replace. 76 | 77 | 78 | --- 79 | 80 | ## Run Example: simple 81 | ```sh 82 | git clone https://github.com/rustonbsd/rustpatcher 83 | cd rustpatcher 84 | cargo build --release --example simple 85 | cargo run --bin rustpatcher sign target/release/examples/simple --key-file ./owner_key_example 86 | 87 | # Run signed app: 88 | ./target/release/examples/simple 89 | 90 | 91 | # if you increase the version in /crates/rustpatcher/Cargo.toml 92 | # and build+sign+start another node, then the first 93 | # node will update via the second node. 94 | ``` 95 | 96 | --- 97 | 98 | ## Network Architecture 99 | 100 | ```mermaid 101 | sequenceDiagram 102 | participant Owner as Owner Node (new version) 103 | participant DTT as DHT Topic Tracker 104 | participant Peer as Peer Node (old) 105 | 106 | Owner->>DTT: Publish PatchInfo(version, size, hash, sig) 107 | Peer->>DTT: Query latest PatchInfo (minute slots) 108 | DTT-->>Peer: Return newest records 109 | Peer->>Owner: Iroh connect (ALPN /rustpatcher//v0) 110 | Peer->>Owner: Auth = sha512(pubkey || unix_minute) 111 | Owner-->>Peer: OK + Patch (postcard) 112 | Peer->>Peer: Verify(hash, size, ed25519(pubkey)) 113 | Peer->>Peer: Atomic replace + optional execv restart 114 | ``` 115 | 116 | - Discovery: [distributed-topic-tracker](https://github.com/rustonbsd/distributed-topic-tracker) minute-slotted records over the DHT 117 | - Transport: [iroh](https://github.com/n0-computer/iroh) QUIC, ALPN namespaced per owner key 118 | - Authentication: rotating hash auth per minute bucket 119 | 120 | --- 121 | 122 | ## Key Processes 123 | 124 | 1. Version propagation 125 | - Running a node publishes a PatchInfo record roughly every minute. 126 | - Records are minute scoped with short TTL to avoid staleness. 127 | - Peers scan current and previous minute for latest version. 128 | 129 | 2. Patch fetch + verification 130 | - Peer connects to other peers with newer version via iroh using an ALPN derived from the owner pubkey. 131 | - Auth: sha512(owner_pub_key || unix_minute(t)) for t ∈ {-1..1}. 132 | - Owner sends the signed patch (postcard-encoded). 133 | 134 | 3. Self-update mechanism 135 | - Write to temp file 136 | - Atomic [self-replace](https://crates.io/crates/self-replace) 137 | - Optional immediate restart via execv (UpdaterMode::Now) or deferred (OnRestart / At(hh, mm)) 138 | 139 | --- 140 | 141 | ## Data Embedded in the Binary 142 | 143 | - Fixed-size embedded region in a dedicated link section (.embedded_signature) 144 | - Layout: 145 | - 28 bytes: bounds start marker 146 | - 32 bytes: binary hash (sha512 truncated to 32) 147 | - 8 bytes: binary size (LE) 148 | - 64 bytes: ed25519 signature 149 | - 16 bytes: ASCII version (padded) 150 | - 28 bytes: bounds end marker 151 | 152 | At runtime, the library: 153 | - Locates the embedded region 154 | - Parses version/hash/size/signature 155 | - Verifies the binary contents against the signed metadata 156 | 157 | --- 158 | 159 | ## CLI Reference (rustpatcher) 160 | 161 | - gen 162 | - Generates a new ed25519 signing key in z-base-32; prints the public key and attribute snippet. 163 | - sign --key-file 164 | - Reads the compiled binary, computes PatchInfo, and writes it into the embedded region. 165 | 166 | DO NOT COMMIT YOUR PRIVATE KEY! 167 | 168 | ```sh 169 | # add this to your .gitignore 170 | owner_key* 171 | ``` 172 | 173 | Notes: 174 | - Keys are z-base-32 encoded on disk, the public key is embedded in code via #[rustpatcher::public_key("...")]. 175 | - Signing must be re-run after each new build that is intendet to self update. 176 | - For every build target a seperate keypair is required (we don't want the arm users patching in x86 binaries). 177 | 178 | --- 179 | 180 | ## Library API (overview) 181 | 182 | - #[rustpatcher::public_key("")] 183 | - Embeds the owner public key and the package version for verification 184 | - rustpatcher::spawn(mode: UpdaterMode) -> Future> 185 | - Starts discovery, publishing, distribution server, and updater 186 | - UpdaterMode::{Now, OnRestart, At(h, m)} 187 | 188 | --- 189 | 190 | ## How It Changed (vs previous rustpatcher) 191 | 192 | - Single embedded region with explicit bounds, constant size, and zero-allocation compile-time construction 193 | - Signature scheme clarified and minimal: 194 | - sha512(data_no_embed) -> first 32 bytes as hash 195 | - sign sha512(version || hash || size_le) with ed25519 196 | - Owner key embedding via attribute macro, version captured from CARGO_PKG_VERSION and embedded as fixed-length ASCII 197 | - Minute-slotted record publishing and discovery via distributed-topic-tracker 198 | - iroh-based distributor with rotating minute auth derived from owner public key 199 | - Simple updater modes: Now, OnRestart, At(hh:mm) 200 | - CLI split: cargo install rustpatcher to manage keys and sign releases 201 | 202 | --- 203 | 204 | ## Example 205 | 206 | ```rust 207 | use rustpatcher::UpdaterMode; 208 | 209 | #[tokio::main] 210 | #[rustpatcher::public_key("axegnqus3miex47g1kxf1j7j8spczbc57go7jgpeixq8nxjfz7gy")] 211 | async fn main() -> anyhow::Result<()> { 212 | 213 | rustpatcher::spawn(UpdaterMode::At(02, 30)).await?; 214 | 215 | // app code... 216 | Ok(()) 217 | } 218 | ``` 219 | 220 | ## Release Workflow 221 | 222 | 1) Generate key (once): 223 | - rustpatcher gen ./owner_key 224 | 225 | 2) Build + sign each release: 226 | - cargo build --release 227 | - rustpatcher sign target/release/ --key-file=./owner_key 228 | 229 | 3) Deploy and run the signed binary on at least one node: 230 | - It will publish PatchInfo and serve patches to peers. 231 | - No need to have any exposed ports. 232 | 233 | ```make 234 | build: 235 | cargo build --release 236 | rustpatcher sign target/release/ --key-file ./owner_key 237 | 238 | publish: 239 | target/release/ 240 | ``` -------------------------------------------------------------------------------- /crates/rustpatcher/src/embed.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use once_cell::sync::OnceCell; 4 | 5 | use crate::{PatchInfo, Version}; 6 | 7 | #[doc(hidden)] 8 | static APP_VERSION: OnceCell<&'static str> = OnceCell::new(); 9 | #[doc(hidden)] 10 | static OWNER_PUB_KEY: OnceCell = OnceCell::new(); 11 | 12 | #[doc(hidden)] 13 | pub fn __set_version(version: &'static str) { 14 | let _ = APP_VERSION.set(version); 15 | } 16 | 17 | #[doc(hidden)] 18 | pub fn __set_owner_pub_key(pub_key: ed25519_dalek::VerifyingKey) { 19 | let _ = OWNER_PUB_KEY.set(pub_key); 20 | } 21 | 22 | pub fn get_owner_pub_key() -> &'static ed25519_dalek::VerifyingKey { 23 | OWNER_PUB_KEY 24 | .get() 25 | .expect("Owner public key not initialized") 26 | } 27 | 28 | pub fn get_app_version() -> &'static str { 29 | APP_VERSION.get().expect("Version not initialized") 30 | } 31 | 32 | // 28_bytes 33 | // hex: 0x1742525553545041544348455242454d42454442424f554e44534217 34 | #[doc(hidden)] 35 | pub static EMBED_BOUNDS: &[u8] = b"\x17\x42RUSTPATCHER\x42EMBED\x42BOUNDS\x42\x17"; 36 | 37 | #[doc(hidden)] 38 | const VERSION_FIELD_LEN: usize = 16; 39 | #[doc(hidden)] 40 | const VERSION_ASCII: &str = env!("CARGO_PKG_VERSION"); 41 | 42 | #[doc(hidden)] 43 | const fn version_field_ascii_padded(s: &str) -> [u8; VERSION_FIELD_LEN] { 44 | let bytes = s.as_bytes(); 45 | let mut out = [0u8; VERSION_FIELD_LEN]; 46 | let mut i = 0; 47 | while i < bytes.len() && i < VERSION_FIELD_LEN { 48 | out[i] = bytes[i]; 49 | i += 1; 50 | } 51 | out 52 | } 53 | 54 | #[doc(hidden)] 55 | const VERSION_BYTES: [u8; VERSION_FIELD_LEN] = version_field_ascii_padded(VERSION_ASCII); 56 | 57 | #[doc(hidden)] 58 | const BIN_HASH: [u8; 32] = [0; 32]; 59 | #[doc(hidden)] 60 | const BIN_SIZE: [u8; 8] = [0; 8]; 61 | #[doc(hidden)] 62 | const BIN_SIG: [u8; 64] = [0; 64]; 63 | #[doc(hidden)] 64 | pub const EMBED_REGION_LEN: usize = 65 | 28 + VERSION_BYTES.len() + BIN_HASH.len() + BIN_SIZE.len() + BIN_SIG.len() + 28; 66 | 67 | // Assert sizes at compile time 68 | #[doc(hidden)] 69 | const _: () = { 70 | assert!(EMBED_BOUNDS.len() == 28); 71 | assert!(VERSION_BYTES.len() == 16); 72 | assert!(EMBED_REGION_LEN == 176); 73 | }; 74 | 75 | // Build const array without any runtime code or allocation 76 | #[doc(hidden)] 77 | #[cfg_attr(target_os = "macos", unsafe(link_section = "__DATA,__embsig"))] 78 | #[cfg_attr(target_os = "linux", unsafe(link_section = ".embsig"))] 79 | #[cfg_attr(target_os = "windows", unsafe(link_section = ".embsig"))] 80 | #[cfg_attr( 81 | not(any(target_os = "macos", target_os = "linux", target_os = "windows")), 82 | unsafe(link_section = ".embsig") 83 | )] 84 | #[used] 85 | #[unsafe(no_mangle)] 86 | pub static EMBED_REGION: [u8; EMBED_REGION_LEN] = { 87 | let mut buf = [0u8; EMBED_REGION_LEN]; 88 | let mut off = 0; 89 | 90 | // bounds start 91 | { 92 | let b = EMBED_BOUNDS; 93 | let mut i = 0; 94 | while i < b.len() { 95 | buf[off + i] = b[i]; 96 | i += 1; 97 | } 98 | off += b.len(); 99 | } 100 | 101 | // bin_hash placeholder 102 | { 103 | let b = BIN_HASH; 104 | let mut i = 0; 105 | while i < b.len() { 106 | buf[off + i] = b[i]; 107 | i += 1; 108 | } 109 | off += b.len(); 110 | } 111 | 112 | // bin_size placeholder 113 | { 114 | let b = BIN_SIZE; 115 | let mut i = 0; 116 | while i < b.len() { 117 | buf[off + i] = b[i]; 118 | i += 1; 119 | } 120 | off += b.len(); 121 | } 122 | 123 | // bin_sig placeholder 124 | { 125 | let b = BIN_SIG; 126 | let mut i = 0; 127 | while i < b.len() { 128 | buf[off + i] = b[i]; 129 | i += 1; 130 | } 131 | off += b.len(); 132 | } 133 | 134 | // padded-str-version 135 | { 136 | let b = VERSION_BYTES; 137 | let mut i = 0; 138 | while i < b.len() { 139 | buf[off + i] = b[i]; 140 | i += 1; 141 | } 142 | off += b.len(); 143 | } 144 | 145 | // bounds end 146 | { 147 | let b = EMBED_BOUNDS; 148 | let mut i = 0; 149 | while i < b.len() { 150 | buf[off + i] = b[i]; 151 | i += 1; 152 | } 153 | } 154 | buf 155 | }; 156 | 157 | #[doc(hidden)] 158 | pub fn embed(version: &'static str, pub_key: &'static str) { 159 | __set_version(version); 160 | __set_owner_pub_key( 161 | z32::decode(pub_key.as_bytes()) 162 | .ok() 163 | .and_then(|k_bytes| { 164 | let key_array: [u8; 32] = k_bytes.try_into().ok()?; 165 | ed25519_dalek::VerifyingKey::from_bytes(&key_array).ok() 166 | }) 167 | .expect("failed to decode public key"), 168 | ); 169 | #[cfg(not(debug_assertions))] 170 | unsafe { 171 | core::ptr::read_volatile(&EMBED_REGION as *const _); 172 | } 173 | } 174 | 175 | #[doc(hidden)] 176 | pub struct EmbeddedRegion { 177 | pub start: usize, 178 | pub end: usize, 179 | } 180 | 181 | #[doc(hidden)] 182 | pub fn cut_embed_section(bin_bytes: &[u8]) -> anyhow::Result<(Vec, Vec, EmbeddedRegion)> { 183 | let start = bin_bytes 184 | .windows(EMBED_BOUNDS.len()) 185 | .position(|window| window == EMBED_BOUNDS) 186 | .ok_or_else(|| anyhow::anyhow!("failed to find embed bounds start"))?; 187 | let end = bin_bytes 188 | .windows(EMBED_BOUNDS.len()) 189 | .rposition(|window| window == EMBED_BOUNDS) 190 | .ok_or_else(|| anyhow::anyhow!("failed to find embed bounds end"))? 191 | + EMBED_BOUNDS.len(); 192 | if end as i128 - start as i128 != EMBED_REGION.len() as i128 { 193 | return Err(anyhow::anyhow!("invalid embed section size")); 194 | } 195 | let mut out = bin_bytes.to_vec(); 196 | let embed_region = out.drain(start..end).collect::>(); 197 | Ok((out, embed_region, EmbeddedRegion { start, end })) 198 | } 199 | 200 | #[doc(hidden)] 201 | pub fn get_embedded_version(embed_region_bytes: &[u8]) -> anyhow::Result { 202 | let version_offset = EMBED_BOUNDS.len() + BIN_HASH.len() + BIN_SIZE.len() + BIN_SIG.len(); 203 | let version_bytes = 204 | embed_region_bytes[version_offset..version_offset + VERSION_FIELD_LEN].to_vec(); 205 | let version_str = std::str::from_utf8(&version_bytes)?; 206 | Version::from_str(version_str.trim_end_matches(char::from(0)).trim()) 207 | } 208 | 209 | #[doc(hidden)] 210 | pub fn get_embedded_patch_info(bin_data: &[u8]) -> anyhow::Result { 211 | let (_, embed_region_bytes, _) = cut_embed_section(bin_data)?; 212 | 213 | let (_, buf) = embed_region_bytes.split_at(EMBED_BOUNDS.len()); 214 | let (hash_buf, buf) = buf.split_at(BIN_HASH.len()); 215 | let (size_buf, buf) = buf.split_at(BIN_SIZE.len()); 216 | let (sig_buf, _) = buf.split_at(BIN_SIG.len()); 217 | 218 | let version = get_embedded_version(&embed_region_bytes)?; 219 | let size = u64::from_le_bytes( 220 | size_buf 221 | .try_into() 222 | .map_err(|_| anyhow::anyhow!("invalid size bytes"))?, 223 | ); 224 | let hash: [u8; 32] = hash_buf 225 | .try_into() 226 | .map_err(|_| anyhow::anyhow!("invalid hash bytes"))?; 227 | let signature: [u8; 64] = sig_buf 228 | .try_into() 229 | .map_err(|_| anyhow::anyhow!("invalid signature bytes"))?; 230 | 231 | Ok(crate::PatchInfo { 232 | version, 233 | size, 234 | hash, 235 | signature: signature.into(), 236 | }) 237 | } 238 | 239 | #[doc(hidden)] 240 | pub fn set_embedded_patch_info( 241 | bin_data: &mut Vec, 242 | patch_info: PatchInfo, 243 | embed_region_bytes: EmbeddedRegion, 244 | ) -> anyhow::Result<()> { 245 | let (start, end) = (embed_region_bytes.start, embed_region_bytes.end); 246 | if end - start != EMBED_REGION_LEN { 247 | return Err(anyhow::anyhow!("invalid embed region length")); 248 | } 249 | 250 | let mut region_buf = Vec::with_capacity(EMBED_REGION_LEN); 251 | region_buf.extend_from_slice(EMBED_BOUNDS); 252 | region_buf.extend_from_slice(&patch_info.hash); 253 | region_buf.extend_from_slice(&patch_info.size.to_le_bytes()); 254 | region_buf.extend_from_slice(&patch_info.signature.to_bytes()); 255 | region_buf.extend_from_slice(&VERSION_BYTES); 256 | region_buf.extend_from_slice(EMBED_BOUNDS); 257 | 258 | if region_buf.len() != EMBED_REGION_LEN { 259 | return Err(anyhow::anyhow!( 260 | "internal error: invalid embed region length" 261 | )); 262 | } 263 | 264 | bin_data.splice(start..end, region_buf.iter().cloned()); 265 | 266 | Ok(()) 267 | } 268 | -------------------------------------------------------------------------------- /.github/workflows/e2e-test.yml: -------------------------------------------------------------------------------- 1 | name: E2E Cross-Platform Update Test 2 | 3 | on: 4 | push: 5 | branches: [main, master] 6 | pull_request: 7 | branches: [main, master] 8 | workflow_dispatch: 9 | 10 | env: 11 | CARGO_TERM_COLOR: always 12 | 13 | jobs: 14 | build-and-test: 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | os: [ubuntu-latest, macos-latest] 19 | include: 20 | - os: ubuntu-latest 21 | platform: linux 22 | binary_ext: "" 23 | key_file: owner_key_linux 24 | timeout_cmd: timeout 25 | - os: macos-latest 26 | platform: macos 27 | binary_ext: "" 28 | key_file: owner_key_macos 29 | timeout_cmd: gtimeout 30 | #- os: windows-latest 31 | # platform: win 32 | # binary_ext: ".exe" 33 | # key_file: owner_key_win 34 | # timeout_cmd: timeout 35 | 36 | runs-on: ${{ matrix.os }} 37 | timeout-minutes: 30 38 | 39 | defaults: 40 | run: 41 | shell: bash 42 | 43 | steps: 44 | - name: Checkout repository 45 | uses: actions/checkout@v4 46 | 47 | - name: Install Rust toolchain 48 | uses: dtolnay/rust-toolchain@stable 49 | 50 | - name: Cache cargo registry 51 | uses: actions/cache@v4 52 | with: 53 | path: ~/.cargo/registry 54 | key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} 55 | 56 | - name: Cache cargo index 57 | uses: actions/cache@v4 58 | with: 59 | path: ~/.cargo/git 60 | key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} 61 | 62 | - name: Cache cargo build 63 | uses: actions/cache@v4 64 | with: 65 | path: target 66 | key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} 67 | 68 | # macOS specific: Install coreutils for gtimeout and verify codesign 69 | - name: Install dependencies (macOS) 70 | if: matrix.os == 'macos-latest' 71 | run: | 72 | brew install coreutils 73 | if ! command -v codesign &> /dev/null; then 74 | echo "codesign command not found!" 75 | exit 1 76 | fi 77 | echo "codesign is available" 78 | codesign --version || true 79 | 80 | - name: Build e2e example (initial version) 81 | working-directory: crates/rustpatcher 82 | run: cargo build --release --example e2e 83 | 84 | - name: Sign initial binary 85 | working-directory: crates/rustpatcher 86 | run: | 87 | cargo run --release --bin rustpatcher sign \ 88 | ../../target/release/examples/e2e${{ matrix.binary_ext }} \ 89 | --key-file e2e_test/keys/${{ matrix.key_file }} 90 | 91 | - name: Save initial binary 92 | run: | 93 | mkdir -p old_version 94 | cp target/release/examples/e2e${{ matrix.binary_ext }} old_version/e2e${{ matrix.binary_ext }} 95 | shell: bash 96 | 97 | - name: Read and bump version 98 | id: version 99 | working-directory: crates/rustpatcher 100 | run: | 101 | # Extract current version from Cargo.toml 102 | CURRENT_VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') 103 | echo "Current version: $CURRENT_VERSION" 104 | 105 | # Parse version components 106 | MAJOR=$(echo $CURRENT_VERSION | cut -d. -f1) 107 | MINOR=$(echo $CURRENT_VERSION | cut -d. -f2) 108 | PATCH=$(echo $CURRENT_VERSION | cut -d. -f3) 109 | 110 | # Bump patch version 111 | NEW_PATCH=$((PATCH + 1)) 112 | NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}" 113 | 114 | echo "New version: $NEW_VERSION" 115 | echo "old_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT 116 | echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT 117 | shell: bash 118 | 119 | - name: Update Cargo.toml version 120 | working-directory: crates/rustpatcher 121 | run: | 122 | # Update version in Cargo.toml 123 | if [ "${{ matrix.os }}" = "windows-latest" ]; then 124 | sed -i 's/version = "${{ steps.version.outputs.old_version }}"/version = "${{ steps.version.outputs.new_version }}"/' Cargo.toml 125 | else 126 | sed -i.bak 's/version = "${{ steps.version.outputs.old_version }}"/version = "${{ steps.version.outputs.new_version }}"/' Cargo.toml 127 | rm -f Cargo.toml.bak 128 | fi 129 | echo "Updated version from ${{ steps.version.outputs.old_version }} to ${{ steps.version.outputs.new_version }}" 130 | grep '^version = ' Cargo.toml 131 | shell: bash 132 | 133 | - name: Build e2e example (new version) 134 | working-directory: crates/rustpatcher 135 | run: cargo build --release --example e2e 136 | 137 | - name: Sign new binary 138 | working-directory: crates/rustpatcher 139 | run: | 140 | cargo run --release --bin rustpatcher sign \ 141 | ../../target/release/examples/e2e${{ matrix.binary_ext }} \ 142 | --key-file e2e_test/keys/${{ matrix.key_file }} 143 | 144 | - name: Save new binary 145 | run: | 146 | mkdir -p new_version 147 | cp target/release/examples/e2e${{ matrix.binary_ext }} new_version/e2e${{ matrix.binary_ext }} 148 | shell: bash 149 | 150 | - name: Make binaries executable 151 | if: matrix.os != 'windows-latest' 152 | run: | 153 | chmod +x old_version/e2e 154 | chmod +x new_version/e2e 155 | 156 | - name: Run E2E test 157 | timeout-minutes: 5 158 | env: 159 | OLD_VERSION: ${{ steps.version.outputs.old_version }} 160 | NEW_VERSION: ${{ steps.version.outputs.new_version }} 161 | run: | 162 | echo "=== Starting E2E Update Test for ${{ matrix.platform }} ===" 163 | echo "Testing P2P update from $OLD_VERSION to $NEW_VERSION" 164 | 165 | # Parse version patterns 166 | OLD_MAJOR=$(echo $OLD_VERSION | cut -d. -f1) 167 | OLD_MINOR=$(echo $OLD_VERSION | cut -d. -f2) 168 | OLD_PATCH=$(echo $OLD_VERSION | cut -d. -f3) 169 | NEW_MAJOR=$(echo $NEW_VERSION | cut -d. -f1) 170 | NEW_MINOR=$(echo $NEW_VERSION | cut -d. -f2) 171 | NEW_PATCH=$(echo $NEW_VERSION | cut -d. -f3) 172 | 173 | OLD_PATTERN="Version($OLD_MAJOR, $OLD_MINOR, $OLD_PATCH)" 174 | NEW_PATTERN="Version($NEW_MAJOR, $NEW_MINOR, $NEW_PATCH)" 175 | 176 | echo "Expected initial version: $OLD_PATTERN" 177 | echo "Expected after update: $NEW_PATTERN" 178 | echo "" 179 | 180 | # Start the NEW version (publisher/server with the update) 181 | echo "--- Starting NEW version binary (publisher) ---" 182 | ./new_version/e2e${{ matrix.binary_ext }} > new_output.txt 2>&1 & 183 | NEW_PID=$! 184 | echo "Started new version with PID: $NEW_PID" 185 | sleep 2 186 | 187 | # Start the OLD version (client that should update itself) 188 | echo "--- Starting OLD version binary (to be updated) ---" 189 | ./old_version/e2e${{ matrix.binary_ext }} > old_output.txt 2>&1 & 190 | OLD_PID=$! 191 | echo "Started old version with PID: $OLD_PID" 192 | 193 | # Monitor old version output for up to 120 seconds (2 minutes) 194 | echo "" 195 | echo "Monitoring for update... (timeout: 120 seconds)" 196 | TIMEOUT=120 197 | ELAPSED=0 198 | UPDATE_SUCCESS=false 199 | 200 | while [ $ELAPSED -lt $TIMEOUT ]; do 201 | if [ -f old_output.txt ]; then 202 | # Check if we see the NEW version pattern (update succeeded) 203 | if grep -q "$NEW_PATTERN" old_output.txt; then 204 | echo "UPDATE SUCCESSFUL! Old version updated to $NEW_PATTERN" 205 | UPDATE_SUCCESS=true 206 | break 207 | fi 208 | fi 209 | sleep 5 210 | ELAPSED=$((ELAPSED + 5)) 211 | echo " ... $ELAPSED seconds elapsed" 212 | done 213 | 214 | # Kill both processes 215 | kill $NEW_PID $OLD_PID 2>/dev/null || true 216 | sleep 1 217 | 218 | # Show outputs 219 | echo "" 220 | echo "=== New version output ===" 221 | cat new_output.txt || echo "No output" 222 | echo "" 223 | echo "=== Old version output ===" 224 | cat old_output.txt || echo "No output" 225 | echo "" 226 | 227 | # Evaluate result 228 | if [ "$UPDATE_SUCCESS" = true ]; then 229 | echo "E2E test PASSED for ${{ matrix.platform }}!" 230 | echo "The old version successfully updated itself via P2P network" 231 | exit 0 232 | else 233 | echo "E2E test FAILED for ${{ matrix.platform }}" 234 | echo "The old version did NOT update to $NEW_PATTERN within 2 minutes" 235 | echo "" 236 | echo "Possible issues:" 237 | echo " - P2P discovery failed" 238 | echo " - Network connectivity issues" 239 | echo " - Update mechanism not triggering" 240 | echo " - Check outputs above for errors" 241 | exit 1 242 | fi 243 | 244 | # Upload artifacts for debugging 245 | - name: Upload test artifacts 246 | if: always() 247 | uses: actions/upload-artifact@v4 248 | with: 249 | name: e2e-test-artifacts-${{ matrix.platform }} 250 | path: | 251 | old_version/ 252 | new_version/ 253 | old_output.txt 254 | new_output.txt 255 | old_error.txt 256 | new_error.txt 257 | retention-days: 7 --------------------------------------------------------------------------------