Skip to main content

max / makenotwork

27.9 KB · 755 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 scp_result.map_err(|e| format!("SCP download failed: {e}"))?;
407
408 // Read signature from .sig file if it was downloaded
409 let signature = if scp_sig_result.is_ok() {
410 let sig = tokio::fs::read_to_string(&local_sig_tmp)
411 .await
412 .unwrap_or_default();
413 let _ = tokio::fs::remove_file(&local_sig_tmp).await;
414 sig
415 } else {
416 String::new()
417 };
418
419 // Upload to S3 via multipart streaming from disk — the previous
420 // implementation `tokio::fs::read` → `Vec<u8>` → `upload_object` pinned
421 // the entire artifact (up to ~100 MB per build) in RAM during upload.
422 // `upload_multipart` reads the file in chunks and lets the S3 SDK do
423 // parallel part uploads, keeping memory bounded regardless of artifact
424 // size.
425 let synckit_s3 = state
426 .synckit_s3
427 .as_ref()
428 .ok_or("SyncKit storage not configured")?;
429
430 let upload_result = synckit_s3
431 .upload_multipart(
432 &s3_key,
433 "application/octet-stream",
434 std::path::Path::new(&local_tmp),
435 )
436 .await
437 .map_err(|e| format!("S3 multipart upload failed: {e}"));
438
439 // Always remove the local temp file, even if the upload failed — leaving
440 // it on disk fills the build runner's tmp directory across retries.
441 let _ = tokio::fs::remove_file(&local_tmp).await;
442
443 upload_result?;
444
445 if !signature.is_empty() {
446 let _ = append_log_bounded(
447 state,
448 build.id,
449 &format!("[{target}] uploaded to {s3_key} (signed)\n"),
450 )
451 .await;
452 } else {
453 let _ = append_log_bounded(
454 state,
455 build.id,
456 &format!("[{target}] uploaded to {s3_key}\n"),
457 )
458 .await;
459 }
460
461 Ok((s3_key, signature))
462 }
463
464 /// Path to a known_hosts file for build SSH connections.
465 /// When present, StrictHostKeyChecking=yes is used (pinned keys).
466 /// When absent, StrictHostKeyChecking=accept-new (trust on first use).
467 const BUILD_SSH_KNOWN_HOSTS: &str = "/opt/makenotwork/ssh/known_hosts";
468
469 /// Run a command on a remote host via SSH.
470 async fn run_ssh_command(host: &str, command: &str) -> std::result::Result<String, String> {
471 let mut args = vec!["-o", "ConnectTimeout=10", "-o", "BatchMode=yes"];
472 let known_hosts_arg;
473 if std::path::Path::new(BUILD_SSH_KNOWN_HOSTS).exists() {
474 args.extend(["-o", "StrictHostKeyChecking=yes", "-o"]);
475 known_hosts_arg = format!("UserKnownHostsFile={BUILD_SSH_KNOWN_HOSTS}");
476 args.push(&known_hosts_arg);
477 } else {
478 args.extend(["-o", "StrictHostKeyChecking=accept-new"]);
479 }
480 args.push(host);
481 args.push(command);
482 let output = tokio::process::Command::new("ssh")
483 .args(&args)
484 .output()
485 .await
486 .map_err(|e| format!("failed to spawn ssh: {e}"))?;
487
488 if output.status.success() {
489 Ok(String::from_utf8_lossy(&output.stdout).to_string())
490 } else {
491 let stderr = String::from_utf8_lossy(&output.stderr);
492 Err(format!(
493 "exit code {}: {}",
494 output.status.code().unwrap_or(-1),
495 stderr.trim()
496 ))
497 }
498 }
499
500 /// Download a file from a remote host via SCP.
501 async fn run_scp_download(
502 host: &str,
503 remote_path: &str,
504 local_path: &str,
505 ) -> std::result::Result<(), String> {
506 let remote = format!("{host}:{remote_path}");
507 let mut args: Vec<&str> = vec!["-o", "ConnectTimeout=10", "-o", "BatchMode=yes"];
508 let known_hosts_arg;
509 if std::path::Path::new(BUILD_SSH_KNOWN_HOSTS).exists() {
510 args.extend(["-o", "StrictHostKeyChecking=yes", "-o"]);
511 known_hosts_arg = format!("UserKnownHostsFile={BUILD_SSH_KNOWN_HOSTS}");
512 args.push(&known_hosts_arg);
513 } else {
514 args.extend(["-o", "StrictHostKeyChecking=accept-new"]);
515 }
516 args.push(&remote);
517 args.push(local_path);
518 let output = tokio::process::Command::new("scp")
519 .args(&args)
520 .output()
521 .await
522 .map_err(|e| format!("failed to spawn scp: {e}"))?;
523
524 if output.status.success() {
525 Ok(())
526 } else {
527 let stderr = String::from_utf8_lossy(&output.stderr);
528 Err(format!(
529 "exit code {}: {}",
530 output.status.code().unwrap_or(-1),
531 stderr.trim()
532 ))
533 }
534 }
535
536 /// Append to build log, respecting the max log size.
537 ///
538 /// Probes `octet_length(log)` instead of fetching the whole row (the log
539 /// column tops out at 5 MiB and is read on every line append).
540 async fn append_log_bounded(
541 state: &AppState,
542 build_id: db::BuildId,
543 line: &str,
544 ) -> crate::error::Result<()> {
545 const TRUNCATED: &str = "[log truncated]\n";
546 if let Some((current_len, already_truncated)) =
547 db::builds::get_build_log_size(&state.db, build_id, TRUNCATED).await?
548 && (current_len as usize) + line.len() > BUILD_MAX_LOG_BYTES
549 {
550 if !already_truncated {
551 tracing::warn!(build_id = %build_id, "Build log exceeded {} bytes, truncating", BUILD_MAX_LOG_BYTES);
552 db::builds::append_build_log(&state.db, build_id, TRUNCATED).await?;
553 }
554 return Ok(());
555 }
556 let sanitized = strip_ansi_escapes(line);
557 db::builds::append_build_log(&state.db, build_id, &sanitized).await
558 }
559
560 /// Strip ANSI escape sequences (e.g. color codes) from build output before
561 /// storing it in the database.
562 fn strip_ansi_escapes(s: &str) -> String {
563 let mut result = String::with_capacity(s.len());
564 let mut chars = s.chars();
565 while let Some(c) = chars.next() {
566 if c == '\x1b' {
567 // Consume the next char; if it's '[' we have a CSI sequence
568 // and we skip parameter/intermediate bytes up to the final byte.
569 // Otherwise (OSC / other sequences) just drop the two-char escape.
570 if let Some(next) = chars.next()
571 && next == '['
572 {
573 // CSI sequence: skip until we hit a letter (0x40..=0x7E).
574 for tail in chars.by_ref() {
575 if tail.is_ascii_alphabetic() {
576 break;
577 }
578 }
579 }
580 } else {
581 result.push(c);
582 }
583 }
584 result
585 }
586
587 /// Validate a build command for shell safety.
588 ///
589 /// Rejects shell metacharacters that enable command chaining or redirection.
590 /// Allowed: alphanumeric, spaces, hyphens, underscores, dots, slashes, equals,
591 /// braces (for template vars), colons, commas, plus signs.
592 pub fn validate_build_command(cmd: &str) -> std::result::Result<(), String> {
593 if cmd.is_empty() {
594 return Err("build command is empty".to_string());
595 }
596 if cmd.len() > 1024 {
597 return Err("build command too long (max 1024 chars)".to_string());
598 }
599 for (i, c) in cmd.chars().enumerate() {
600 match c {
601 'a'..='z' | 'A'..='Z' | '0'..='9' => {}
602 ' ' | '-' | '_' | '.' | '/' | '=' | ':' | ',' | '+' | '{' | '}' | '@' => {}
603 ';' | '&' | '|' | '$' | '`' | '(' | ')' | '<' | '>' | '!' | '\\' | '\'' | '"'
604 | '\n' | '\r' | '\0' => {
605 return Err(format!(
606 "shell metacharacter '{}' at position {} is not allowed",
607 c.escape_default(),
608 i
609 ));
610 }
611 _ => {
612 return Err(format!(
613 "unexpected character '{}' at position {} is not allowed",
614 c.escape_default(),
615 i
616 ));
617 }
618 }
619 }
620 Ok(())
621 }
622
623 /// Validate an artifact path for shell and path safety.
624 ///
625 /// Must be a relative path with no shell metacharacters or path traversal.
626 pub fn validate_artifact_path(path: &str) -> std::result::Result<(), String> {
627 if path.is_empty() {
628 return Err("artifact path is empty".to_string());
629 }
630 if path.len() > 512 {
631 return Err("artifact path too long (max 512 chars)".to_string());
632 }
633 if path.starts_with('/') {
634 return Err("artifact path must be relative".to_string());
635 }
636 if path.contains("..") {
637 return Err("artifact path must not contain '..'".to_string());
638 }
639 for (i, c) in path.chars().enumerate() {
640 match c {
641 'a'..='z' | 'A'..='Z' | '0'..='9' => {}
642 '-' | '_' | '.' | '/' | '{' | '}' | '+' => {}
643 ';' | '&' | '|' | '$' | '`' | '(' | ')' | '<' | '>' | '!' | '\\' | '\'' | '"'
644 | ' ' | '\n' | '\r' | '\0' => {
645 return Err(format!(
646 "character '{}' at position {} is not allowed in artifact path",
647 c.escape_default(),
648 i
649 ));
650 }
651 _ => {
652 return Err(format!(
653 "unexpected character '{}' at position {} is not allowed in artifact path",
654 c.escape_default(),
655 i
656 ));
657 }
658 }
659 }
660 Ok(())
661 }
662
663 /// Escape a string for safe use in a shell command.
664 fn shell_escape(s: &str) -> String {
665 format!("'{}'", s.replace('\'', "'\\''"))
666 }
667
668 #[cfg(test)]
669 mod tests {
670 use super::*;
671
672 #[test]
673 fn build_failure_message_partial() {
674 assert_eq!(
675 build_failure_message(1, 2, Some("boom")),
676 "partial build failure (1/3 targets succeeded)"
677 );
678 assert_eq!(
679 build_failure_message(2, 1, Some("boom")),
680 "partial build failure (2/3 targets succeeded)"
681 );
682 }
683
684 #[test]
685 fn build_failure_message_total_failure_uses_first_error() {
686 assert_eq!(build_failure_message(0, 3, Some("ssh down")), "ssh down");
687 assert_eq!(build_failure_message(0, 0, None), "no targets produced artifacts");
688 }
689
690 #[test]
691 fn rust_target_mapping() {
692 assert_eq!(rust_target("linux", "x86_64"), Some("x86_64-unknown-linux-gnu"));
693 assert_eq!(rust_target("linux", "aarch64"), Some("aarch64-unknown-linux-gnu"));
694 assert_eq!(rust_target("darwin", "x86_64"), Some("x86_64-apple-darwin"));
695 assert_eq!(rust_target("darwin", "aarch64"), Some("aarch64-apple-darwin"));
696 assert_eq!(rust_target("windows", "x86_64"), None);
697 }
698
699 #[test]
700 fn hook_template_contains_hmac_not_raw_token() {
701 let hook = post_receive_hook("secret-token-123", "alice", "myrepo");
702 let expected_hmac = repo_hmac("secret-token-123", "alice", "myrepo");
703 assert!(hook.contains(&expected_hmac), "hook should contain per-repo HMAC");
704 assert!(!hook.contains("secret-token-123"), "hook must not contain raw token");
705 assert!(!hook.contains("__HMAC__"), "placeholder should be replaced");
706 assert!(hook.contains("/api/internal/builds/trigger"));
707 }
708
709 #[test]
710 fn repo_hmac_differs_per_repo() {
711 let h1 = repo_hmac("token", "alice", "repo-a");
712 let h2 = repo_hmac("token", "alice", "repo-b");
713 assert_ne!(h1, h2, "different repos should produce different HMACs");
714 }
715
716 #[test]
717 fn shell_escape_basic() {
718 assert_eq!(shell_escape("hello"), "'hello'");
719 assert_eq!(shell_escape("it's"), "'it'\\''s'");
720 }
721
722 #[test]
723 fn validate_build_command_accepts_safe_commands() {
724 assert!(validate_build_command("cargo build --release --target x86_64-unknown-linux-gnu").is_ok());
725 assert!(validate_build_command("make -j4").is_ok());
726 assert!(validate_build_command("RUSTFLAGS=--cfg tokio_unstable cargo build").is_ok());
727 }
728
729 #[test]
730 fn validate_build_command_rejects_injection() {
731 assert!(validate_build_command("cargo build; curl evil.com").is_err());
732 assert!(validate_build_command("cargo build && rm -rf /").is_err());
733 assert!(validate_build_command("cargo build | tee log").is_err());
734 assert!(validate_build_command("$(whoami)").is_err());
735 assert!(validate_build_command("`whoami`").is_err());
736 assert!(validate_build_command("cargo build > /dev/null").is_err());
737 assert!(validate_build_command("").is_err());
738 }
739
740 #[test]
741 fn validate_artifact_path_accepts_safe_paths() {
742 assert!(validate_artifact_path("target/x86_64-unknown-linux-gnu/release/myapp").is_ok());
743 assert!(validate_artifact_path("dist/app-v0.1.0.tar.gz").is_ok());
744 }
745
746 #[test]
747 fn validate_artifact_path_rejects_unsafe() {
748 assert!(validate_artifact_path("/etc/passwd").is_err());
749 assert!(validate_artifact_path("../../../etc/passwd").is_err());
750 assert!(validate_artifact_path("path with spaces").is_err());
751 assert!(validate_artifact_path("$(whoami)").is_err());
752 assert!(validate_artifact_path("").is_err());
753 }
754 }
755