Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all --check
cargo run -p botloftd -- serve # com BOTLOFT_HOME de dev (abaixo)
cargo run -p botloftd -- service install # tarefa agendada própria da pasta de dev; `service uninstall` depois

# Tipos TS gerados a partir do Rust
cargo test -p botloft-core export_bindings
Expand Down Expand Up @@ -68,6 +69,7 @@ Em dev, use `$env:BOTLOFT_HOME = "$PWD\.dev\home"` (caminho absoluto) no daemon
- Permissão de arquivo é ACL (SID do usuário atual), não modo Unix.
- Estado em `%LOCALAPPDATA%`, nunca `%APPDATA%` (roaming).
- Cuidado com o limite de 260 caracteres em caminhos de workspace.
- Tarefa agendada (spec 14): `RestartOnFailure` não reinicia quando o processo sai com erro, e a repetição de um gatilho de logon só começa no próximo logon. Quem traz o daemon de volta é um gatilho de horário repetido a cada minuto com `IgnoreNew`. Use a API COM do Agendador: a saída do `schtasks.exe` é traduzida.

## Segurança e privacidade

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion crates/botloftd/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
workspace = true

[target."cfg(windows)".dependencies]
windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading", "Win32_System_Memory", "Win32_System_JobObjects", "Win32_System_Environment"] }
windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading", "Win32_System_Memory", "Win32_System_JobObjects", "Win32_System_Environment", "Win32_System_Power", "Win32_System_Console", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Variant", "Win32_System_TaskScheduler"] }

[target."cfg(windows)".build-dependencies]
embed-resource = "3.0.11"

[dev-dependencies]
base64 = "0.23.1"
Expand Down
18 changes: 18 additions & 0 deletions crates/botloftd/botloftd.manifest
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 and 11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<!-- Paths past 260 characters, for bots that run npm install (spec 14). -->
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
<!-- No console window when the scheduled task starts the daemon; a
terminal still shares its console (Windows 11 24H2 and later). -->
<consoleAllocationPolicy xmlns="http://schemas.microsoft.com/SMI/2024/WindowsSettings">detached</consoleAllocationPolicy>
</windowsSettings>
</application>
</assembly>
3 changes: 3 additions & 0 deletions crates/botloftd/botloftd.rc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// The daemon's Windows resources: only its manifest (spec 14).
#define RT_MANIFEST 24
1 RT_MANIFEST "botloftd.manifest"
14 changes: 14 additions & 0 deletions crates/botloftd/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//! Embeds `botloftd.manifest` in the Windows binary (spec 14). It goes in
//! as a resource: the linker's own manifest tool predates
//! `consoleAllocationPolicy` and warns about it on every build.

fn main() {
println!("cargo:rerun-if-changed=botloftd.rc");
println!("cargo:rerun-if-changed=botloftd.manifest");
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows")
&& let Err(err) =
embed_resource::compile("botloftd.rc", embed_resource::NONE).manifest_required()
{
panic!("cannot embed the manifest: {err}");
}
}
175 changes: 175 additions & 0 deletions crates/botloftd/src/autostart/binary.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
//! The daemon runs from its own copy in `<home>\bin`, never from the app's
//! folder, so the app's installer can replace its files while the daemon
//! runs (spec 14).

use std::fs;
use std::io;
use std::path::{Path, PathBuf};

pub const EXE_NAME: &str = if cfg!(windows) {
"botloftd.exe"
} else {
"botloftd"
};
const STAGED: &str = "botloftd.new";
const OLD_SUFFIX: &str = ".old";

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Installed {
pub path: PathBuf,
/// Whether a different binary was put in place.
pub changed: bool,
}

/// Copies `source` to `<bin_dir>\botloftd.exe`, unless it is that file or
/// has the same bytes. A running exe cannot be overwritten but can be
/// renamed, so the old one moves aside and is deleted on a later install.
pub fn install(source: &Path, bin_dir: &Path) -> io::Result<Installed> {
fs::create_dir_all(bin_dir)?;
remove_old(bin_dir);
let target = bin_dir.join(EXE_NAME);
let unchanged = Installed {
path: target.clone(),
changed: false,
};
if is_same_file(source, &target) {
return Ok(unchanged);
}
let bytes = fs::read(source)?;
match fs::read(&target) {
Ok(current) if current == bytes => return Ok(unchanged),
Ok(_) => {}
Err(err) if err.kind() == io::ErrorKind::NotFound => {}
Err(err) => return Err(err),
}
let staged = bin_dir.join(STAGED);
fs::write(&staged, &bytes)?;
if target.exists() {
fs::rename(&target, aside(bin_dir))?;
}
fs::rename(&staged, &target)?;
Ok(Installed {
path: target,
changed: true,
})
}

/// Deletes the installed binary and what was moved aside, as far as
/// nothing still runs them.
pub fn remove(bin_dir: &Path) {
let _ = fs::remove_file(bin_dir.join(EXE_NAME));
let _ = fs::remove_file(bin_dir.join(STAGED));
remove_old(bin_dir);
let _ = fs::remove_dir(bin_dir);
}

fn is_same_file(a: &Path, b: &Path) -> bool {
match (fs::canonicalize(a), fs::canonicalize(b)) {
(Ok(a), Ok(b)) => a == b,
_ => false,
}
}

/// A free `botloftd.<n>.old` name. A previous one may still be running.
fn aside(bin_dir: &Path) -> PathBuf {
(1..)
.map(|n| bin_dir.join(format!("botloftd.{n}{OLD_SUFFIX}")))
.find(|path| !path.exists())
.unwrap_or_else(|| bin_dir.join(format!("botloftd{OLD_SUFFIX}")))
}

fn remove_old(bin_dir: &Path) {
let Ok(entries) = fs::read_dir(bin_dir) else {
return;
};
for entry in entries.flatten() {
if entry.file_name().to_string_lossy().ends_with(OLD_SUFFIX) {
// Still running: it goes next time.
let _ = fs::remove_file(entry.path());
}
}
}

#[cfg(test)]
mod tests {
use super::*;

fn names(dir: &Path) -> Vec<String> {
let mut names: Vec<String> = fs::read_dir(dir)
.expect("read dir")
.map(|entry| {
entry
.expect("entry")
.file_name()
.to_string_lossy()
.into_owned()
})
.collect();
names.sort();
names
}

#[test]
fn copies_once_and_replaces_only_a_different_binary() {
let dir = tempfile::tempdir().expect("tempdir");
let source = dir.path().join("sidecar.exe");
let bin = dir.path().join("bin");
fs::write(&source, b"v1").expect("write");

let first = install(&source, &bin).expect("install");
assert!(first.changed);
assert_eq!(fs::read(&first.path).expect("read"), b"v1");
assert!(!install(&source, &bin).expect("again").changed);
assert!(
!install(&first.path, &bin).expect("itself").changed,
"running install from the installed copy changes nothing"
);

fs::write(&source, b"v2").expect("write");
let second = install(&source, &bin).expect("update");
assert!(second.changed);
assert_eq!(fs::read(&second.path).expect("read"), b"v2");
assert_eq!(names(&bin), ["botloftd.1.old".to_owned(), EXE_NAME.into()]);

fs::write(&source, b"v3").expect("write");
install(&source, &bin).expect("update");
assert_eq!(
names(&bin),
["botloftd.1.old".to_owned(), EXE_NAME.into()],
"the v1 copy went away and v2 moved aside"
);

remove(&bin);
assert!(!bin.exists());
}

/// A running exe is open with share-delete: it can be renamed, not
/// written.
#[cfg(windows)]
#[test]
fn replaces_a_binary_that_is_running() {
use std::os::windows::fs::OpenOptionsExt as _;
const FILE_SHARE_READ: u32 = 1;
const FILE_SHARE_DELETE: u32 = 4;

let dir = tempfile::tempdir().expect("tempdir");
let source = dir.path().join("sidecar.exe");
let bin = dir.path().join("bin");
fs::write(&source, b"v1").expect("write");
let installed = install(&source, &bin).expect("install");
let running = fs::OpenOptions::new()
.read(true)
.share_mode(FILE_SHARE_READ | FILE_SHARE_DELETE)
.open(&installed.path)
.expect("open");
assert!(
fs::write(&installed.path, b"x").is_err(),
"cannot be written"
);

fs::write(&source, b"v2").expect("write");
let updated = install(&source, &bin).expect("update");
assert_eq!(fs::read(&updated.path).expect("read"), b"v2");
drop(running);
}
}
50 changes: 50 additions & 0 deletions crates/botloftd/src/autostart/health.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//! Asking a daemon on 127.0.0.1 whether it is up, over plain HTTP.

use std::io::{Read as _, Write as _};
use std::net::{Ipv4Addr, SocketAddr, TcpStream};
use std::time::{Duration, Instant};

use serde::Deserialize;

const TIMEOUT: Duration = Duration::from_secs(2);

#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct Health {
pub status: String,
pub version: String,
pub protocol: u32,
}

/// The daemon's `/health`, or `None` if nothing Botloft answers.
pub fn probe(port: u16) -> Option<Health> {
let address = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
let mut stream = TcpStream::connect_timeout(&address, TIMEOUT).ok()?;
stream.set_read_timeout(Some(TIMEOUT)).ok()?;
stream.set_write_timeout(Some(TIMEOUT)).ok()?;
let request = "GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n";
stream.write_all(request.as_bytes()).ok()?;
let mut response = Vec::new();
stream.read_to_end(&mut response).ok()?;
let response = String::from_utf8_lossy(&response);
let (head, body) = response.split_once("\r\n\r\n")?;
if !head.starts_with("HTTP/1.1 200") {
return None;
}
serde_json::from_str::<Health>(body)
.ok()
.filter(|health| health.status == "ok")
}

/// Polls until `done` holds for the probe, or `limit` passes.
pub fn wait(port: u16, limit: Duration, done: impl Fn(Option<&Health>) -> bool) -> bool {
let started = Instant::now();
loop {
if done(probe(port).as_ref()) {
return true;
}
if started.elapsed() > limit {
return false;
}
std::thread::sleep(Duration::from_millis(250));
}
}
Loading
Loading