Skip to main content

max / makenotwork

48.0 KB · 1262 lines History Blame Raw
1 //! Build runner, dispatches and executes OTA builds via SSH to remote hosts.
2 //!
3 //! The scheduler calls `dispatch_pending_build()` each tick. If no build is
4 //! running and one is pending, it spawns a `tokio::spawn` task that SSHes to
5 //! the appropriate build host, clones, builds, signs, and uploads artifacts.
6
7 use std::sync::Arc;
8 use std::sync::atomic::{AtomicUsize, Ordering};
9 use std::time::Duration;
10
11 use axum::extract::FromRef;
12
13 use crate::AppState;
14 use crate::config::Config;
15 use crate::constants::{BUILD_MAX_LOG_BYTES, BUILD_TIMEOUT_SECS};
16 use crate::db::{self, BuildStatus, DbBuild, DbBuildConfig};
17 use crate::scanning::ScanPipeline;
18 use crate::storage::StorageBackend;
19 use crate::wam_client::WamClient;
20
21 /// The slice of [`AppState`] the OTA build runner needs: the DB pool it drives
22 /// every query through, the config for build-host lookup, the optional scanner
23 /// it enqueues uploaded artifacts onto, the SyncKit blob bucket it uploads to,
24 /// and the optional WAM client for build-failure tickets.
25 ///
26 /// Field names mirror [`AppState`] (`ctx.db`, `ctx.config`, `ctx.scanner`,
27 /// `ctx.wam`) so the runner body reads like the pre-decomposition code. This is a
28 /// projection view (like the handler slices in [`crate`]); [`dispatch_pending_build`]
29 /// takes it instead of the whole `AppState`, so the runner's dependencies are
30 /// stated, not ambient. `synckit_s3` is flattened out of `AppStorage` here since
31 /// the runner touches only that one bucket.
32 #[derive(Clone)]
33 pub struct BuildCtx {
34 pub db: sqlx::PgPool,
35 pub config: Config,
36 pub scanner: Option<Arc<ScanPipeline>>,
37 pub synckit_s3: Option<Arc<dyn StorageBackend>>,
38 pub wam: Option<WamClient>,
39 }
40
41 impl FromRef<AppState> for BuildCtx {
42 fn from_ref(s: &AppState) -> Self {
43 Self {
44 db: s.db.clone(),
45 config: s.config.clone(),
46 scanner: s.scanner.clone(),
47 synckit_s3: s.storage.synckit_s3.clone(),
48 wam: s.wam.clone(),
49 }
50 }
51 }
52
53 /// Wall-clock cap for an artifact SCP download. Only the build ssh was
54 /// timeout-wrapped; a build host that stalled mid-transfer wedged the single
55 /// build slot forever (Run 15 Resilience). Artifacts can be large, so this is
56 /// generous; `kill_on_drop` reaps the scp when the timed-out future is dropped.
57 const SCP_TRANSFER_TIMEOUT_SECS: u64 = 600;
58
59 /// Wall-clock cap for best-effort remote cleanup (`rm -rf` of the build dir).
60 /// Short: cleanup should be near-instant, and a stalled cleanup must not hold
61 /// the build slot.
62 const SSH_CLEANUP_TIMEOUT_SECS: u64 = 60;
63
64 /// Best-effort remote build-dir cleanup with a bounded wall-clock. `kill_on_drop`
65 /// in `run_ssh_command` reaps the ssh process if the timeout fires.
66 async fn cleanup_remote_dir(host: &str, build_dir: &str) {
67 let cmd = format!("rm -rf {}", shell_escape(build_dir));
68 // Genuinely nothing to do on failure: the build must not be held up over a
69 // leftover directory, and a retry on a host that just timed out would hold
70 // the slot longer. It still gets named, because the host is now carrying a
71 // build dir nobody will remove and that is an operator's problem.
72 match tokio::time::timeout(
73 Duration::from_secs(SSH_CLEANUP_TIMEOUT_SECS),
74 Box::pin(run_ssh_command(host, &cmd)),
75 )
76 .await
77 {
78 Ok(Ok(_)) => {}
79 Ok(Err(e)) => {
80 tracing::warn!(host, build_dir, error = %e, "remote build dir cleanup failed");
81 }
82 Err(_) => {
83 tracing::warn!(host, build_dir, "remote build dir cleanup timed out");
84 }
85 }
86 }
87
88 /// Post-receive hook script template.
89 /// `__HMAC__` is replaced with a per-repo HMAC signature so the global token
90 /// is never stored on disk. The server verifies via `HMAC(token, owner:repo)`.
91 ///
92 /// Both curl calls run backgrounded so they never block the git push, but
93 /// their stdout+stderr is appended to `hooks/post-receive.log` next to this
94 /// script. A non-zero curl exit also writes a "FAILED" line with the exit
95 /// code, so a build that never triggers is diagnosable from the repo rather
96 /// than from "why didn't anything happen." The log is append-only and grows
97 /// unbounded; truncate or rotate via the host's logrotate.
98 const POST_RECEIVE_HOOK_TEMPLATE: &str = r#"#!/bin/bash
99 REPO_PATH="$(cd "$(dirname "$0")/.." && pwd)"
100 LOG="$REPO_PATH/hooks/post-receive.log"
101 REPO_NAME="$(basename "$REPO_PATH" .git)"
102 OWNER="$(basename "$(dirname "$REPO_PATH")")"
103 while read oldrev newrev refname; do
104 case "$refname" in
105 refs/tags/v[0-9]*)
106 TAG="${refname#refs/tags/}"
107 ( exec >>"$LOG" 2>&1
108 echo "[$(date -u +%FT%TZ)] tag-push $OWNER/$REPO_NAME tag=$TAG"
109 curl -sf -X POST \
110 -H "Authorization: Bearer __HMAC__" \
111 -H "Content-Type: application/json" \
112 -d "{\"repo_owner\": \"$OWNER\", \"repo_name\": \"$REPO_NAME\", \"tag\": \"$TAG\"}" \
113 "http://localhost:3000/api/internal/builds/trigger" \
114 || echo "[$(date -u +%FT%TZ)] FAILED builds/trigger exit=$?"
115 ) &
116 ;;
117 refs/heads/*)
118 BRANCH="${refname#refs/heads/}"
119 ( exec >>"$LOG" 2>&1
120 echo "[$(date -u +%FT%TZ)] branch-push $OWNER/$REPO_NAME branch=$BRANCH"
121 curl -sf -X POST \
122 -H "Authorization: Bearer __HMAC__" \
123 -H "Content-Type: application/json" \
124 -d "{\"repo_owner\": \"$OWNER\", \"repo_name\": \"$REPO_NAME\", \"ref_name\": \"$BRANCH\", \"before\": \"$oldrev\", \"after\": \"$newrev\"}" \
125 "http://localhost:3000/api/internal/issues/process-push" \
126 || echo "[$(date -u +%FT%TZ)] FAILED issues/process-push exit=$?"
127 ) &
128 ;;
129 esac
130 done
131 "#;
132
133 /// Compute a per-repo HMAC so the global token never touches disk.
134 pub fn repo_hmac(token: &str, owner: &str, repo: &str) -> String {
135 use hmac::{Hmac, KeyInit, Mac};
136 use sha2::Sha256;
137 let mut mac =
138 Hmac::<Sha256>::new_from_slice(token.as_bytes()).expect("HMAC accepts any key length");
139 mac.update(format!("{owner}:{repo}").as_bytes());
140 hex::encode(mac.finalize().into_bytes())
141 }
142
143 /// Generate the post-receive hook script with a per-repo HMAC signature.
144 pub fn post_receive_hook(token: &str, owner: &str, repo: &str) -> String {
145 let hmac = repo_hmac(token, owner, repo);
146 POST_RECEIVE_HOOK_TEMPLATE.replace("__HMAC__", &hmac)
147 }
148
149 /// Map (os, arch) to a Rust target triple.
150 pub fn rust_target(os: &str, arch: &str) -> Option<&'static str> {
151 match (os, arch) {
152 ("linux", "x86_64") => Some("x86_64-unknown-linux-gnu"),
153 ("linux", "aarch64") => Some("aarch64-unknown-linux-gnu"),
154 ("darwin", "x86_64") => Some("x86_64-apple-darwin"),
155 ("darwin", "aarch64") => Some("aarch64-apple-darwin"),
156 _ => None,
157 }
158 }
159
160 /// Get the SSH build host for a target OS from config.
161 fn build_host_for_target<'a>(config: &'a crate::config::Config, os: &str) -> Option<&'a str> {
162 match os {
163 "linux" => config.build.host_linux.as_deref(),
164 "darwin" => config.build.host_darwin.as_deref(),
165 _ => None,
166 }
167 }
168
169 /// Check for a pending build and spawn it if no build is currently running.
170 ///
171 /// Called from the scheduler loop. Non-blocking, spawns the build task and returns.
172 #[tracing::instrument(skip_all, name = "build_runner::dispatch")]
173 pub async fn dispatch_pending_build(ctx: &BuildCtx) {
174 // Recover from stale running builds (e.g. server crashed mid-build)
175 match db::builds::fail_stale_running_builds(&ctx.db, BUILD_TIMEOUT_SECS as i64).await {
176 Ok(n) if n > 0 => {
177 tracing::warn!(count = n, "marked stale running builds as failed");
178 }
179 Err(e) => {
180 tracing::error!(error = ?e, "failed to check stale builds");
181 }
182 _ => {}
183 }
184
185 let build = match db::builds::claim_pending_build(&ctx.db).await {
186 Ok(Some(b)) => b,
187 Ok(None) => return,
188 Err(e) => {
189 tracing::error!(error = ?e, "failed to claim pending build");
190 return;
191 }
192 };
193
194 let config = match db::builds::get_build_config_by_app(&ctx.db, build.app_id).await {
195 Ok(Some(c)) => c,
196 Ok(None) => {
197 tracing::error!(build_id = %build.id, "build config not found for pending build");
198 if let Err(e) = db::builds::update_build_status(
199 &ctx.db,
200 build.id,
201 BuildStatus::Failed,
202 Some("Build config not found"),
203 )
204 .await
205 {
206 tracing::error!(build_id = %build.id, error = ?e, "failed to mark build as failed (config not found)");
207 }
208 return;
209 }
210 Err(e) => {
211 tracing::error!(error = ?e, "failed to get build config");
212 return;
213 }
214 };
215
216 let ctx = ctx.clone();
217 tokio::spawn(async move {
218 run_build(&ctx, &build, &config).await;
219 });
220 }
221
222 fn build_failure_message(succeeded: usize, failed: usize, first_error: Option<&str>) -> String {
223 if succeeded == 0 {
224 first_error
225 .unwrap_or("no targets produced artifacts")
226 .to_string()
227 } else {
228 let total = succeeded + failed;
229 format!("partial build failure ({succeeded}/{total} targets succeeded)")
230 }
231 }
232
233 /// A successfully built target: `(target_os, arch, s3_key, signature)`.
234 type TargetArtifact = (String, String, String, String);
235 /// A failed target: `(target_str, error)`.
236 type TargetError = (String, String);
237 /// One host group's results: its artifacts and its per-target failures.
238 type GroupOutput = (Vec<TargetArtifact>, Vec<TargetError>);
239
240 /// Execute a full build: iterate targets, SSH to hosts, build, upload artifacts.
241 #[tracing::instrument(skip_all, name = "build_runner::run_build", fields(build_id = %build.id, version = %build.version))]
242 async fn run_build(ctx: &BuildCtx, build: &DbBuild, config: &DbBuildConfig) {
243 let mut artifact_keys: Vec<(String, String, String, String)> = Vec::new(); // (target_os, arch, s3_key, signature)
244 let mut failed_count: usize = 0;
245 let mut first_error: Option<String> = None;
246 // Shared across the per-host tasks, so a log line dropped inside any target
247 // still reaches the final status message.
248 let log_drops = Arc::new(AtomicUsize::new(0));
249
250 // Resolve each target to its build host up front. Synchronous failures (bad
251 // target format, no host configured) are tallied here; resolvable targets are
252 // grouped by host so independent hosts (e.g. linux vs darwin) build
253 // concurrently while same-host targets stay serial, a multi-target release no
254 // longer serializes end to end at up to 30 min/target (Perf-S2, Run 9).
255 let mut groups: Vec<(String, Vec<(String, String)>)> = Vec::new(); // host -> [(os, arch)]
256 for target_str in &config.targets {
257 let Some((target_os, arch)): Option<(&str, &str)> = target_str.split_once('/') else {
258 let msg = format!("invalid target format: {target_str}\n");
259 append_log(ctx, build.id, &msg, &log_drops).await;
260 failed_count += 1;
261 if first_error.is_none() {
262 first_error = Some(format!("invalid target format: {target_str}"));
263 }
264 continue;
265 };
266
267 let Some(host) = build_host_for_target(&ctx.config, target_os) else {
268 let msg = format!("no build host for {target_os}, skipping {target_str}\n");
269 tracing::warn!("{}", msg.trim());
270 append_log(ctx, build.id, &msg, &log_drops).await;
271 failed_count += 1;
272 if first_error.is_none() {
273 first_error = Some(format!("no build host for {target_os}"));
274 }
275 continue;
276 };
277
278 let entry = (target_os.to_string(), arch.to_string());
279 match groups.iter_mut().find(|(h, _)| h == host) {
280 Some((_, targets)) => targets.push(entry),
281 None => groups.push((host.to_string(), vec![entry])),
282 }
283 }
284
285 // One task per host; targets within a host run serially. Results are gathered
286 // by group index so the merged artifact order stays deterministic regardless
287 // of which host finishes first.
288 let group_count = groups.len();
289 let mut set: tokio::task::JoinSet<(usize, GroupOutput)> = tokio::task::JoinSet::new();
290 for (idx, (host, targets)) in groups.into_iter().enumerate() {
291 let ctx = ctx.clone();
292 let build = build.clone();
293 let config = config.clone();
294 let log_drops = Arc::clone(&log_drops);
295 set.spawn(async move {
296 let mut oks: Vec<TargetArtifact> = Vec::new();
297 let mut errs: Vec<TargetError> = Vec::new();
298 for (target_os, arch) in &targets {
299 match Box::pin(execute_target(
300 &ctx, &build, &config, &host, target_os, arch, &log_drops,
301 ))
302 .await
303 {
304 Ok((s3_key, signature)) => {
305 oks.push((target_os.clone(), arch.clone(), s3_key, signature));
306 }
307 Err(e) => errs.push((format!("{target_os}/{arch}"), e)),
308 }
309 }
310 (idx, (oks, errs))
311 });
312 }
313
314 let mut gathered: Vec<Option<GroupOutput>> = (0..group_count).map(|_| None).collect();
315 while let Some(res) = set.join_next().await {
316 match res {
317 Ok((idx, out)) => gathered[idx] = Some(out),
318 Err(e) => {
319 // A host group's task panicked; the build can't be considered
320 // complete, so fail it rather than silently dropping its targets.
321 tracing::error!(error = ?e, "build host group task panicked");
322 failed_count += 1;
323 if first_error.is_none() {
324 first_error = Some("a build host group task panicked".to_string());
325 }
326 }
327 }
328 }
329
330 for (oks, errs) in gathered.into_iter().flatten() {
331 artifact_keys.extend(oks);
332 for (target_str, e) in errs {
333 let msg = format!("target {target_str} failed: {e}\n");
334 tracing::error!("{}", msg.trim());
335 append_log(ctx, build.id, &msg, &log_drops).await;
336 failed_count += 1;
337 if first_error.is_none() {
338 first_error = Some(e);
339 }
340 }
341 }
342
343 if artifact_keys.is_empty() || failed_count > 0 {
344 let mut err_msg =
345 build_failure_message(artifact_keys.len(), failed_count, first_error.as_deref());
346 if let Some(note) = incomplete_log_note(&log_drops) {
347 err_msg.push_str(&note);
348 }
349 if let Err(e) =
350 db::builds::update_build_status(&ctx.db, build.id, BuildStatus::Failed, Some(&err_msg))
351 .await
352 {
353 tracing::error!(build_id = %build.id, error = ?e, "failed to mark build as failed");
354 }
355 if let Some(ref wam) = ctx.wam {
356 let title = format!("Build failed: {} v{}", build.tag, build.version);
357 wam.create_ticket(
358 &title,
359 Some(&err_msg),
360 "high",
361 "build-failed",
362 Some(&build.id.to_string()),
363 )
364 .await;
365 }
366 return;
367 }
368
369 // Every artifact is signed independently and served with its own signature.
370 // An unsigned artifact can never be installed (Tauri refuses an unsigned
371 // update), so if any successful target lacks a signature, fail the build
372 // loudly rather than publishing a release with a dead platform.
373 if let Some((target_os, arch, _, _)) =
374 artifact_keys.iter().find(|(_, _, _, sig)| sig.is_empty())
375 {
376 let msg = format!(
377 "build produced an unsigned artifact ({target_os}/{arch}); refusing to publish"
378 );
379 if let Err(e) =
380 db::builds::update_build_status(&ctx.db, build.id, BuildStatus::Failed, Some(&msg))
381 .await
382 {
383 tracing::error!(build_id = %build.id, error = ?e, "failed to mark build failed (missing signature)");
384 }
385 return;
386 }
387
388 // Create OTA release (only for fully successful builds)
389 let release = match db::ota::create_release(
390 &ctx.db,
391 build.app_id,
392 &build.version,
393 &format!("Automated build from tag {}", build.tag),
394 )
395 .await
396 {
397 Ok(r) => r,
398 Err(e) => {
399 let msg = format!("failed to create OTA release: {e}");
400 if let Err(e) =
401 db::builds::update_build_status(&ctx.db, build.id, BuildStatus::Failed, Some(&msg))
402 .await
403 {
404 tracing::error!(build_id = %build.id, error = ?e, "failed to mark build as failed (release creation)");
405 }
406 return;
407 }
408 };
409
410 // The app owner is the responsible identity for the artifact scans.
411 let owner_id = db::synckit::get_sync_app_by_id(&ctx.db, build.app_id)
412 .await
413 .ok()
414 .flatten()
415 .map(|app| app.creator_id);
416
417 // Record artifacts and enqueue each for malware scanning. The artifact stays
418 // `pending` (not served) until the scan clears it, same gate as the item
419 // channel.
420 for (target_os, arch, s3_key, signature) in &artifact_keys {
421 // Get file size from S3 via HEAD request (best-effort, use 0 if unavailable)
422 let file_size = if let Some(s3) = ctx.synckit_s3.as_ref() {
423 s3.object_size(s3_key).await.ok().flatten().unwrap_or(0)
424 } else {
425 0
426 };
427
428 match db::ota::create_artifact(
429 &ctx.db, release.id, target_os, arch, s3_key, file_size, signature,
430 )
431 .await
432 {
433 Ok(artifact) => {
434 if let Some(owner_id) = owner_id
435 && let Err(e) = crate::routes::ota::enqueue_ota_artifact_scan(
436 &ctx.db,
437 ctx.scanner.as_ref(),
438 artifact.id,
439 s3_key,
440 owner_id,
441 file_size,
442 )
443 .await
444 {
445 tracing::error!(artifact_id = %artifact.id, error = ?e, "failed to enqueue OTA artifact scan");
446 }
447 }
448 Err(e) => tracing::error!(error = ?e, "failed to record artifact"),
449 }
450 }
451
452 // Link build to release
453 if let Err(e) = db::builds::set_build_release(&ctx.db, build.id, release.id).await {
454 tracing::error!(build_id = %build.id, release_id = %release.id, error = ?e, "failed to link build to release");
455 }
456
457 // All targets succeeded (partial failures return early above). The build is
458 // still a success if log lines were lost, but the row says so rather than
459 // presenting a short log as the whole story.
460 let note = incomplete_log_note(&log_drops);
461 if let Err(e) =
462 db::builds::update_build_status(&ctx.db, build.id, BuildStatus::Succeeded, note.as_deref())
463 .await
464 {
465 tracing::error!(build_id = %build.id, error = ?e, "failed to mark build as succeeded");
466 }
467
468 tracing::info!(
469 build_id = %build.id,
470 version = %build.version,
471 artifacts = artifact_keys.len(),
472 "build succeeded"
473 );
474 }
475
476 /// Execute a single target: SSH to host, clone, build, upload artifact.
477 async fn execute_target(
478 ctx: &BuildCtx,
479 build: &DbBuild,
480 config: &DbBuildConfig,
481 host: &str,
482 target_os: &str,
483 arch: &str,
484 log_drops: &AtomicUsize,
485 ) -> std::result::Result<(String, String), String> {
486 let target = format!("{target_os}/{arch}");
487 let rust_triple =
488 rust_target(target_os, arch).ok_or_else(|| format!("unsupported target: {target}"))?;
489
490 // Look up repo for clone URL
491 let repo = db::git_repos::get_repo_by_id(&ctx.db, config.repo_id)
492 .await
493 .map_err(|e| format!("failed to look up repo: {e}"))?
494 .ok_or("repo not found")?;
495
496 let repo_owner = db::users::get_user_by_id(&ctx.db, repo.user_id)
497 .await
498 .map_err(|e| format!("failed to look up repo owner: {e}"))?
499 .ok_or("repo owner not found")?;
500
501 let git_root = ctx
502 .config
503 .build
504 .git_repos_path
505 .as_deref()
506 .ok_or("git_repos_path not configured")?;
507
508 let clone_path = format!("{git_root}/{}/{}.git", repo_owner.username, repo.name);
509 let build_dir = format!("/tmp/mnw-build-{}", build.id);
510
511 // Template substitution for build_command and artifact_path
512 let build_cmd = config
513 .build_command
514 .replace("{target}", rust_triple)
515 .replace("{version}", &build.version);
516 let artifact_path = config
517 .artifact_path
518 .replace("{target}", rust_triple)
519 .replace("{version}", &build.version);
520
521 // Parse build_command into a structured, fully-escaped remote command and
522 // validate artifact_path before interpolation into the shell script.
523 let remote_cmd =
524 RemoteCommand::parse(&build_cmd).map_err(|e| format!("invalid build command: {e}"))?;
525 validate_artifact_path(&artifact_path).map_err(|e| format!("invalid artifact path: {e}"))?;
526
527 // Build the SSH command sequence. Every interpolated value is shell-escaped:
528 // the git/cd/test scaffolding args via `shell_escape`, and the operator build
529 // command via `RemoteCommand::render` (each token escaped, env applied via
530 // `env`). No operator- or record-derived byte reaches the shell unescaped.
531 let remote_script = format!(
532 "set -e && \
533 git clone --depth 1 --branch {tag} {clone_path} {build_dir} && \
534 cd {build_dir} && \
535 {build_cmd} && \
536 test -f {artifact_path}",
537 tag = shell_escape(&build.tag),
538 clone_path = shell_escape(&clone_path),
539 build_dir = shell_escape(&build_dir),
540 build_cmd = remote_cmd.render(),
541 artifact_path = shell_escape(&artifact_path),
542 );
543
544 let log_msg = format!("[{target}] building on {host}...\n");
545 append_log(ctx, build.id, &log_msg, log_drops).await;
546
547 // Execute via SSH with timeout
548 let ssh_result = tokio::time::timeout(
549 Duration::from_secs(BUILD_TIMEOUT_SECS),
550 Box::pin(run_ssh_command(host, &remote_script)),
551 )
552 .await;
553
554 let output = match ssh_result {
555 Ok(Ok(output)) => output,
556 Ok(Err(e)) => {
557 // Cleanup remote build dir (best-effort)
558 Box::pin(cleanup_remote_dir(host, &build_dir)).await;
559 return Err(format!("SSH command failed: {e}"));
560 }
561 Err(_) => {
562 Box::pin(cleanup_remote_dir(host, &build_dir)).await;
563 return Err("build timed out".to_string());
564 }
565 };
566
567 append_log(
568 ctx,
569 build.id,
570 &format!("[{target}] {}\n", output.trim()),
571 log_drops,
572 )
573 .await;
574
575 // SCP artifact back and upload to S3
576 let s3_key = crate::storage::S3Client::generate_ota_artifact_key(
577 build.app_id,
578 &build.version,
579 target_os,
580 arch,
581 );
582
583 // Copy artifact from remote to local temp
584 let local_tmp = format!("/tmp/mnw-artifact-{}-{target_os}-{arch}", build.id);
585 let scp_remote_path = format!(
586 "{}/{}",
587 build_dir.trim_end_matches('/'),
588 artifact_path.trim_start_matches('/')
589 );
590 let scp_result = run_scp_download(host, &scp_remote_path, &local_tmp).await;
591
592 // Best-effort: try to download the .sig file (Tauri builds produce one)
593 let local_sig_tmp = format!("{local_tmp}.sig");
594 let scp_sig_result =
595 run_scp_download(host, &format!("{scp_remote_path}.sig"), &local_sig_tmp).await;
596
597 Box::pin(cleanup_remote_dir(host, &build_dir)).await;
598
599 if let Err(e) = scp_result {
600 // The main artifact failed, but the .sig sidecar may already be on disk
601 // from its own scp above. Clean it up before bailing so a retry loop
602 // doesn't accumulate orphaned .sig temp files (the main temp is removed
603 // unconditionally further down, but on this early return it was never
604 // created).
605 remove_temp_file(&local_sig_tmp).await;
606 return Err(format!("SCP download failed: {e}"));
607 }
608
609 // Read signature from .sig file if it was downloaded
610 let signature = if scp_sig_result.is_ok() {
611 let sig = tokio::fs::read_to_string(&local_sig_tmp)
612 .await
613 .unwrap_or_default();
614 remove_temp_file(&local_sig_tmp).await;
615 sig
616 } else {
617 String::new()
618 };
619
620 // Upload to S3 via multipart streaming from disk, the previous
621 // implementation `tokio::fs::read` → `Vec<u8>` → `upload_object` pinned
622 // the entire artifact (up to ~100 MB per build) in RAM during upload.
623 // `upload_multipart` reads the file in chunks and lets the S3 SDK do
624 // parallel part uploads, keeping memory bounded regardless of artifact
625 // size.
626 let synckit_s3 = ctx
627 .synckit_s3
628 .as_ref()
629 .ok_or("SyncKit storage not configured")?;
630
631 let upload_result = synckit_s3
632 .upload_multipart(
633 &s3_key,
634 "application/octet-stream",
635 std::path::Path::new(&local_tmp),
636 )
637 .await
638 .map_err(|e| format!("S3 multipart upload failed: {e}"));
639
640 // Always remove the local temp file, even if the upload failed, leaving
641 // it on disk fills the build runner's tmp directory across retries.
642 remove_temp_file(&local_tmp).await;
643
644 upload_result?;
645
646 if signature.is_empty() {
647 append_log(
648 ctx,
649 build.id,
650 &format!("[{target}] uploaded to {s3_key}\n"),
651 log_drops,
652 )
653 .await;
654 } else {
655 append_log(
656 ctx,
657 build.id,
658 &format!("[{target}] uploaded to {s3_key} (signed)\n"),
659 log_drops,
660 )
661 .await;
662 }
663
664 Ok((s3_key.into_string(), signature))
665 }
666
667 /// Path to a known_hosts file for build SSH connections.
668 /// When present, StrictHostKeyChecking=yes is used (pinned keys).
669 /// When absent, StrictHostKeyChecking=accept-new (trust on first use).
670 const BUILD_SSH_KNOWN_HOSTS: &str = "/etc/mnw/known_hosts";
671
672 /// SSH/SCP host-key verification options. Pins host keys
673 /// (`StrictHostKeyChecking=yes`) when the known_hosts file is present; otherwise
674 /// falls back to trust-on-first-use AND logs a warning, so an unprovisioned
675 /// known_hosts is visible in the logs rather than silently accepting any host
676 /// key. Returned owned so callers can push them straight into an argv vector.
677 /// Shared by [`run_ssh_command`] and [`run_scp_download`] so the two can't drift.
678 fn ssh_host_key_args() -> Vec<String> {
679 if std::path::Path::new(BUILD_SSH_KNOWN_HOSTS).exists() {
680 vec![
681 "-o".into(),
682 "StrictHostKeyChecking=yes".into(),
683 "-o".into(),
684 format!("UserKnownHostsFile={BUILD_SSH_KNOWN_HOSTS}"),
685 ]
686 } else {
687 tracing::warn!(
688 known_hosts = BUILD_SSH_KNOWN_HOSTS,
689 "build SSH known_hosts file absent; falling back to trust-on-first-use (accept-new). \
690 Provision {BUILD_SSH_KNOWN_HOSTS} to pin build-host keys",
691 );
692 vec!["-o".into(), "StrictHostKeyChecking=accept-new".into()]
693 }
694 }
695
696 /// Run a command on a remote host via SSH.
697 async fn run_ssh_command(host: &str, command: &str) -> std::result::Result<String, String> {
698 let mut args: Vec<String> = vec![
699 "-o".into(),
700 "ConnectTimeout=10".into(),
701 "-o".into(),
702 "BatchMode=yes".into(),
703 ];
704 args.extend(ssh_host_key_args());
705 args.push(host.to_string());
706 args.push(command.to_string());
707 let mut child = tokio::process::Command::new("ssh")
708 .args(&args)
709 .stdin(std::process::Stdio::null())
710 .stdout(std::process::Stdio::piped())
711 .stderr(std::process::Stdio::piped())
712 // Kill the ssh process if this future is dropped (e.g. the 30-min build
713 // timeout fires): otherwise the dropped future leaves ssh, and the
714 // remote build it drives, running orphaned (ultra-fuzz Run 11 Perf).
715 .kill_on_drop(true)
716 .spawn()
717 .map_err(|e| format!("failed to spawn ssh: {e}"))?;
718
719 let stdout_pipe = child.stdout.take().expect("stdout piped");
720 let stderr_pipe = child.stderr.take().expect("stderr piped");
721
722 // Stream both pipes with a per-stream cap instead of Command::output(), which
723 // would buffer all of a chatty 30-min build's output in RAM before the log
724 // cap is ever applied. read_capped keeps draining past the cap (so the child
725 // never blocks on a full pipe) but only retains the first BUILD_MAX_LOG_BYTES
726 // (ultra-fuzz Run 10 Perf S2). Reading both concurrently with wait() avoids
727 // the single-pipe-fills-and-deadlocks hazard.
728 let (stdout_buf, stderr_buf, status) = tokio::join!(
729 read_capped(stdout_pipe, BUILD_MAX_LOG_BYTES),
730 read_capped(stderr_pipe, BUILD_MAX_LOG_BYTES),
731 child.wait(),
732 );
733 let status = status.map_err(|e| format!("ssh wait failed: {e}"))?;
734
735 if status.success() {
736 Ok(stdout_buf)
737 } else {
738 Err(format!(
739 "exit code {}: {}",
740 status.code().unwrap_or(-1),
741 stderr_buf.trim()
742 ))
743 }
744 }
745
746 /// Drain an async reader to completion but retain only the first `cap` bytes.
747 /// Draining past the cap keeps the child process from blocking on a full pipe
748 /// buffer; retaining only `cap` bounds memory regardless of output volume.
749 async fn read_capped<R>(mut reader: R, cap: usize) -> String
750 where
751 R: tokio::io::AsyncRead + Unpin,
752 {
753 use tokio::io::AsyncReadExt;
754 let mut kept = Vec::new();
755 let mut chunk = [0u8; 8192];
756 loop {
757 match reader.read(&mut chunk).await {
758 Ok(0) | Err(_) => break,
759 Ok(n) => {
760 if kept.len() < cap {
761 let take = n.min(cap - kept.len());
762 kept.extend_from_slice(&chunk[..take]);
763 }
764 }
765 }
766 }
767 String::from_utf8_lossy(&kept).into_owned()
768 }
769
770 /// Download a file from a remote host via SCP.
771 async fn run_scp_download(
772 host: &str,
773 remote_path: &str,
774 local_path: &str,
775 ) -> std::result::Result<(), String> {
776 let remote = format!("{host}:{remote_path}");
777 let mut args: Vec<String> = vec![
778 "-o".into(),
779 "ConnectTimeout=10".into(),
780 "-o".into(),
781 "BatchMode=yes".into(),
782 ];
783 args.extend(ssh_host_key_args());
784 args.push(remote);
785 args.push(local_path.to_string());
786 let scp = tokio::process::Command::new("scp")
787 .args(&args)
788 // Kill scp if this future is dropped (build timeout, or the transfer
789 // timeout below) rather than leaving an orphaned transfer running
790 // (ultra-fuzz Run 11 Perf).
791 .kill_on_drop(true)
792 .output();
793 // Bound the whole transfer: a build host that stalls mid-stream would
794 // otherwise hang the build task and pin the single build slot forever
795 // (Run 15 Resilience). ConnectTimeout only covers connection setup.
796 let output =
797 match tokio::time::timeout(Duration::from_secs(SCP_TRANSFER_TIMEOUT_SECS), scp).await {
798 Ok(r) => r.map_err(|e| format!("failed to spawn scp: {e}"))?,
799 Err(_) => return Err("scp transfer timed out".to_string()),
800 };
801
802 if output.status.success() {
803 Ok(())
804 } else {
805 let stderr = String::from_utf8_lossy(&output.stderr);
806 Err(format!(
807 "exit code {}: {}",
808 output.status.code().unwrap_or(-1),
809 stderr.trim()
810 ))
811 }
812 }
813
814 /// Append to build log, respecting the max log size.
815 ///
816 /// Probes `octet_length(log)` instead of fetching the whole row (the log
817 /// column tops out at 5 MiB and is read on every line append).
818 /// Remove a build temp file, naming it if it could not be removed.
819 ///
820 /// The caller is either bailing out or done with the file, so there is nothing
821 /// to propagate to; what matters is that a temp file left behind is visible,
822 /// since these accumulate across retries and fill the runner's tmp directory.
823 /// A missing file is the expected case on the error paths and is not a failure.
824 async fn remove_temp_file(path: &str) {
825 match tokio::fs::remove_file(path).await {
826 Ok(()) => {}
827 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
828 Err(e) => tracing::warn!(path, error = %e, "failed to remove build temp file"),
829 }
830 }
831
832 /// A one-line note for the build row when log lines were lost, or `None` when
833 /// the log is whole.
834 fn incomplete_log_note(drops: &AtomicUsize) -> Option<String> {
835 match drops.load(Ordering::Relaxed) {
836 0 => None,
837 n => Some(format!(
838 " (build log incomplete: {n} line(s) could not be stored)"
839 )),
840 }
841 }
842
843 /// Append a build-log line, counting the line against `drops` if it could not
844 /// be stored.
845 ///
846 /// Nothing useful can be done at the call site (the line is already produced
847 /// and the build is mid-flight), but a build whose log silently lost lines must
848 /// not finish looking clean: `run_build` reads the counter and says so on the
849 /// build row. That is the difference between "the build printed nothing here"
850 /// and "we failed to write down what it printed".
851 async fn append_log(ctx: &BuildCtx, build_id: db::BuildId, line: &str, drops: &AtomicUsize) {
852 if let Err(e) = append_log_bounded(ctx, build_id, line).await {
853 drops.fetch_add(1, Ordering::Relaxed);
854 tracing::error!(build_id = %build_id, error = ?e, "build log append failed; build log is incomplete");
855 }
856 }
857
858 async fn append_log_bounded(
859 ctx: &BuildCtx,
860 build_id: db::BuildId,
861 line: &str,
862 ) -> crate::error::Result<()> {
863 const TRUNCATED: &str = "[log truncated]\n";
864 if let Some((current_len, already_truncated)) =
865 db::builds::get_build_log_size(&ctx.db, build_id, TRUNCATED).await?
866 && (current_len as usize) + line.len() > BUILD_MAX_LOG_BYTES
867 {
868 if !already_truncated {
869 tracing::warn!(build_id = %build_id, "Build log exceeded {} bytes, truncating", BUILD_MAX_LOG_BYTES);
870 db::builds::append_build_log(&ctx.db, build_id, TRUNCATED).await?;
871 }
872 return Ok(());
873 }
874 let sanitized = strip_ansi_escapes(line);
875 db::builds::append_build_log(&ctx.db, build_id, &sanitized).await
876 }
877
878 /// Strip ANSI escape sequences (e.g. color codes) from build output before
879 /// storing it in the database.
880 fn strip_ansi_escapes(s: &str) -> String {
881 let mut result = String::with_capacity(s.len());
882 let mut chars = s.chars();
883 while let Some(c) = chars.next() {
884 if c == '\x1b' {
885 // Consume the next char; if it's '[' we have a CSI sequence
886 // and we skip parameter/intermediate bytes up to the final byte.
887 // Otherwise (OSC / other sequences) just drop the two-char escape.
888 if let Some(next) = chars.next()
889 && next == '['
890 {
891 // CSI sequence: skip until we hit a letter (0x40..=0x7E).
892 for tail in chars.by_ref() {
893 if tail.is_ascii_alphabetic() {
894 break;
895 }
896 }
897 }
898 } else {
899 result.push(c);
900 }
901 }
902 result
903 }
904
905 /// A build command parsed into a shell-injection-proof structured form.
906 ///
907 /// The operator-configured `build_command` is a single string (e.g.
908 /// `RUSTFLAGS=--cfg cargo build --release`). Rather than interpolate it raw into
909 /// the remote `sh -c` script, where its safety rested entirely on a
910 /// metacharacter denylist, one added allowed character away from reopening
911 /// injection, it is tokenised into leading `NAME=VALUE` environment
912 /// assignments followed by a program and its arguments. `render` emits every
913 /// element individually shell-escaped, applying assignments via `env`, so no
914 /// operator byte can break out of its shell word. Shell injection is
915 /// structurally impossible here, not denylist-gated; the per-token charset
916 /// check below is defense-in-depth, no longer the sole guard.
917 struct RemoteCommand {
918 /// Leading `NAME=VALUE` assignments, applied via `env` before the program.
919 assignments: Vec<String>,
920 /// The program to execute.
921 program: String,
922 /// The program's arguments.
923 args: Vec<String>,
924 }
925
926 impl RemoteCommand {
927 /// Parse a (template-substituted) build command string into its structured
928 /// form. Tokenised on ASCII whitespace; leading `NAME=VALUE` tokens become
929 /// env assignments, the first remaining token is the program, the rest are
930 /// arguments. Each token is charset-validated as defense-in-depth.
931 fn parse(cmd: &str) -> std::result::Result<Self, String> {
932 if cmd.len() > 1024 {
933 return Err("build command too long (max 1024 chars)".to_string());
934 }
935 let tokens: Vec<&str> = cmd.split_whitespace().collect();
936 if tokens.is_empty() {
937 return Err("build command is empty".to_string());
938 }
939 for tok in &tokens {
940 validate_command_token(tok)?;
941 }
942
943 let mut assignments = Vec::new();
944 let mut rest = tokens.as_slice();
945 while let Some((first, tail)) = rest.split_first() {
946 if is_env_assignment(first) {
947 assignments.push((*first).to_string());
948 rest = tail;
949 } else {
950 break;
951 }
952 }
953
954 let (program, args) = rest.split_first().ok_or_else(|| {
955 "build command has environment assignments but no program".to_string()
956 })?;
957
958 Ok(Self {
959 assignments,
960 program: (*program).to_string(),
961 args: args.iter().map(|s| (*s).to_string()).collect(),
962 })
963 }
964
965 /// Render as a single shell command line with every element escaped. Safe to
966 /// interpolate into a larger `sh -c` script: no element can inject.
967 fn render(&self) -> String {
968 let mut parts = Vec::with_capacity(self.assignments.len() + self.args.len() + 2);
969 if !self.assignments.is_empty() {
970 parts.push("env".to_string());
971 parts.extend(self.assignments.iter().map(|a| shell_escape(a)));
972 }
973 parts.push(shell_escape(&self.program));
974 parts.extend(self.args.iter().map(|a| shell_escape(a)));
975 parts.join(" ")
976 }
977 }
978
979 /// True if a token is a `NAME=VALUE` environment assignment (a valid shell
980 /// identifier before the first `=`).
981 fn is_env_assignment(tok: &str) -> bool {
982 match tok.split_once('=') {
983 Some((name, _)) => {
984 !name.is_empty()
985 && name.chars().enumerate().all(|(i, c)| {
986 c == '_' || c.is_ascii_alphabetic() || (i > 0 && c.is_ascii_digit())
987 })
988 }
989 None => false,
990 }
991 }
992
993 /// Validate a single build-command token's charset. Rejects shell
994 /// metacharacters and control characters as defense-in-depth; the rendered
995 /// command escapes every token regardless, so this is not the sole guard.
996 fn validate_command_token(tok: &str) -> std::result::Result<(), String> {
997 for (i, c) in tok.chars().enumerate() {
998 match c {
999 'a'..='z' | 'A'..='Z' | '0'..='9' => {}
1000 '-' | '_' | '.' | '/' | '=' | ':' | ',' | '+' | '{' | '}' | '@' => {}
1001 _ => {
1002 return Err(format!(
1003 "character '{}' at position {} in token '{}' is not allowed",
1004 c.escape_default(),
1005 i,
1006 tok.escape_default(),
1007 ));
1008 }
1009 }
1010 }
1011 Ok(())
1012 }
1013
1014 /// Validate a build command for shell safety at config-write time. Validation is
1015 /// exactly "parses into a [`RemoteCommand`]", the same parser the executor uses
1016 ///, so a stored command that validates here can never fail to render safely.
1017 pub fn validate_build_command(cmd: &str) -> std::result::Result<(), String> {
1018 RemoteCommand::parse(cmd).map(|_| ())
1019 }
1020
1021 /// Validate an artifact path for shell and path safety.
1022 ///
1023 /// Must be a relative path with no shell metacharacters or path traversal.
1024 pub fn validate_artifact_path(path: &str) -> std::result::Result<(), String> {
1025 if path.is_empty() {
1026 return Err("artifact path is empty".to_string());
1027 }
1028 if path.len() > 512 {
1029 return Err("artifact path too long (max 512 chars)".to_string());
1030 }
1031 if path.starts_with('/') {
1032 return Err("artifact path must be relative".to_string());
1033 }
1034 if path.contains("..") {
1035 return Err("artifact path must not contain '..'".to_string());
1036 }
1037 for (i, c) in path.chars().enumerate() {
1038 match c {
1039 'a'..='z' | 'A'..='Z' | '0'..='9' => {}
1040 '-' | '_' | '.' | '/' | '{' | '}' | '+' => {}
1041 ';' | '&' | '|' | '$' | '`' | '(' | ')' | '<' | '>' | '!' | '\\' | '\'' | '"' | ' '
1042 | '\n' | '\r' | '\0' => {
1043 return Err(format!(
1044 "character '{}' at position {} is not allowed in artifact path",
1045 c.escape_default(),
1046 i
1047 ));
1048 }
1049 _ => {
1050 return Err(format!(
1051 "unexpected character '{}' at position {} is not allowed in artifact path",
1052 c.escape_default(),
1053 i
1054 ));
1055 }
1056 }
1057 }
1058 Ok(())
1059 }
1060
1061 /// Escape a string for safe use in a shell command.
1062 fn shell_escape(s: &str) -> String {
1063 format!("'{}'", s.replace('\'', "'\\''"))
1064 }
1065
1066 #[cfg(test)]
1067 mod tests {
1068 use super::*;
1069
1070 #[tokio::test]
1071 async fn read_capped_truncates_to_cap() {
1072 // 10k bytes through a 4k cap retains exactly 4k (the rest is drained and
1073 // discarded so the child never blocks on a full pipe).
1074 let data = vec![b'x'; 10_000];
1075 let out = read_capped(&data[..], 4096).await;
1076 assert_eq!(out.len(), 4096);
1077 }
1078
1079 #[tokio::test]
1080 async fn read_capped_returns_all_when_under_cap() {
1081 let out = read_capped(&b"hello world"[..], 4096).await;
1082 assert_eq!(out, "hello world");
1083 }
1084
1085 #[test]
1086 fn build_failure_message_partial() {
1087 assert_eq!(
1088 build_failure_message(1, 2, Some("boom")),
1089 "partial build failure (1/3 targets succeeded)"
1090 );
1091 assert_eq!(
1092 build_failure_message(2, 1, Some("boom")),
1093 "partial build failure (2/3 targets succeeded)"
1094 );
1095 }
1096
1097 #[test]
1098 fn build_failure_message_total_failure_uses_first_error() {
1099 assert_eq!(build_failure_message(0, 3, Some("ssh down")), "ssh down");
1100 assert_eq!(
1101 build_failure_message(0, 0, None),
1102 "no targets produced artifacts"
1103 );
1104 }
1105
1106 #[test]
1107 fn rust_target_mapping() {
1108 assert_eq!(
1109 rust_target("linux", "x86_64"),
1110 Some("x86_64-unknown-linux-gnu")
1111 );
1112 assert_eq!(
1113 rust_target("linux", "aarch64"),
1114 Some("aarch64-unknown-linux-gnu")
1115 );
1116 assert_eq!(rust_target("darwin", "x86_64"), Some("x86_64-apple-darwin"));
1117 assert_eq!(
1118 rust_target("darwin", "aarch64"),
1119 Some("aarch64-apple-darwin")
1120 );
1121 assert_eq!(rust_target("windows", "x86_64"), None);
1122 }
1123
1124 #[test]
1125 fn hook_template_contains_hmac_not_raw_token() {
1126 let hook = post_receive_hook("secret-token-123", "alice", "myrepo");
1127 let expected_hmac = repo_hmac("secret-token-123", "alice", "myrepo");
1128 assert!(
1129 hook.contains(&expected_hmac),
1130 "hook should contain per-repo HMAC"
1131 );
1132 assert!(
1133 !hook.contains("secret-token-123"),
1134 "hook must not contain raw token"
1135 );
1136 assert!(!hook.contains("__HMAC__"), "placeholder should be replaced");
1137 assert!(hook.contains("/api/internal/builds/trigger"));
1138 }
1139
1140 /// Fixed vector, duplicated in mnw-cli's `repo_hmac` test. mnw-cli installs
1141 /// hooks for repos it auto-creates over SSH, and this endpoint verifies
1142 /// them; if either side's derivation moves, both tests have to move
1143 /// together or those pushes stop triggering builds.
1144 #[test]
1145 fn repo_hmac_matches_mnw_cli_vector() {
1146 assert_eq!(
1147 repo_hmac("test-token", "max", "repo"),
1148 "198b4a4f542c0c27c4ca333030dfe09fe670aa44bae34d7a586481bb13fd9eb0"
1149 );
1150 }
1151
1152 #[test]
1153 fn repo_hmac_differs_per_repo() {
1154 let h1 = repo_hmac("token", "alice", "repo-a");
1155 let h2 = repo_hmac("token", "alice", "repo-b");
1156 assert_ne!(h1, h2, "different repos should produce different HMACs");
1157 }
1158
1159 #[test]
1160 fn shell_escape_basic() {
1161 assert_eq!(shell_escape("hello"), "'hello'");
1162 assert_eq!(shell_escape("it's"), "'it'\\''s'");
1163 }
1164
1165 #[test]
1166 fn validate_build_command_accepts_safe_commands() {
1167 assert!(
1168 validate_build_command("cargo build --release --target x86_64-unknown-linux-gnu")
1169 .is_ok()
1170 );
1171 assert!(validate_build_command("make -j4").is_ok());
1172 assert!(validate_build_command("RUSTFLAGS=--cfg tokio_unstable cargo build").is_ok());
1173 }
1174
1175 #[test]
1176 fn validate_build_command_rejects_injection() {
1177 assert!(validate_build_command("cargo build; curl evil.com").is_err());
1178 assert!(validate_build_command("cargo build && rm -rf /").is_err());
1179 assert!(validate_build_command("cargo build | tee log").is_err());
1180 assert!(validate_build_command("$(whoami)").is_err());
1181 assert!(validate_build_command("`whoami`").is_err());
1182 assert!(validate_build_command("cargo build > /dev/null").is_err());
1183 assert!(validate_build_command("").is_err());
1184 assert!(
1185 validate_build_command(" ").is_err(),
1186 "whitespace-only has no program"
1187 );
1188 assert!(
1189 validate_build_command("FOO=bar").is_err(),
1190 "assignment with no program"
1191 );
1192 }
1193
1194 #[test]
1195 fn remote_command_parse_separates_env_program_args() {
1196 let c = RemoteCommand::parse("cargo build --release").unwrap();
1197 assert!(c.assignments.is_empty());
1198 assert_eq!(c.program, "cargo");
1199 assert_eq!(c.args, vec!["build", "--release"]);
1200
1201 let c = RemoteCommand::parse("RUSTFLAGS=--cfg CARGO_INCREMENTAL=0 cargo build").unwrap();
1202 assert_eq!(
1203 c.assignments,
1204 vec!["RUSTFLAGS=--cfg", "CARGO_INCREMENTAL=0"]
1205 );
1206 assert_eq!(c.program, "cargo");
1207 assert_eq!(c.args, vec!["build"]);
1208 }
1209
1210 #[test]
1211 fn remote_command_render_escapes_every_token() {
1212 // Plain command: each token individually single-quoted.
1213 let c = RemoteCommand::parse("cargo build --release").unwrap();
1214 assert_eq!(c.render(), "'cargo' 'build' '--release'");
1215
1216 // Env prefix: applied via `env`, each element escaped.
1217 let c = RemoteCommand::parse("RUSTFLAGS=--cfg cargo build").unwrap();
1218 assert_eq!(c.render(), "env 'RUSTFLAGS=--cfg' 'cargo' 'build'");
1219 }
1220
1221 #[test]
1222 fn is_env_assignment_recognizes_valid_identifiers_only() {
1223 assert!(is_env_assignment("FOO=bar"));
1224 assert!(is_env_assignment("_X1=y"));
1225 assert!(is_env_assignment("A=")); // empty value is a valid assignment
1226 assert!(
1227 !is_env_assignment("1FOO=bar"),
1228 "identifier can't start with a digit"
1229 );
1230 assert!(!is_env_assignment("cargo"), "no '='");
1231 assert!(!is_env_assignment("--target=x"), "not a shell identifier");
1232 }
1233
1234 #[test]
1235 fn render_defuses_would_be_injection_even_if_charset_bypassed() {
1236 // Construct a RemoteCommand directly with a hostile arg (bypassing the
1237 // token charset check) to prove render() is the real guard: the shell
1238 // sees a single quoted word, not a command separator.
1239 let c = RemoteCommand {
1240 assignments: vec![],
1241 program: "cargo".to_string(),
1242 args: vec!["build; rm -rf /".to_string()],
1243 };
1244 assert_eq!(c.render(), "'cargo' 'build; rm -rf /'");
1245 }
1246
1247 #[test]
1248 fn validate_artifact_path_accepts_safe_paths() {
1249 assert!(validate_artifact_path("target/x86_64-unknown-linux-gnu/release/myapp").is_ok());
1250 assert!(validate_artifact_path("dist/app-v0.1.0.tar.gz").is_ok());
1251 }
1252
1253 #[test]
1254 fn validate_artifact_path_rejects_unsafe() {
1255 assert!(validate_artifact_path("/etc/passwd").is_err());
1256 assert!(validate_artifact_path("../../../etc/passwd").is_err());
1257 assert!(validate_artifact_path("path with spaces").is_err());
1258 assert!(validate_artifact_path("$(whoami)").is_err());
1259 assert!(validate_artifact_path("").is_err());
1260 }
1261 }
1262