Skip to main content

max / makenotwork

57.4 KB · 1475 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. 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 refs/notes/*)
130 # A note that arrived by push. Backgrounded like the builds and
131 # issues arms: the index is a projection of what the push already
132 # landed, so there is nothing to tell the pusher and nothing they
133 # would do about it. The inbox arm below is the one that answers.
134 ( exec >>"$LOG" 2>&1
135 echo "[$(date -u +%FT%TZ)] notes-index $OWNER/$REPO_NAME ref=$refname"
136 curl -sf -X POST \
137 -H "Authorization: Bearer __HMAC__" \
138 -H "Content-Type: application/json" \
139 -d "{\"repo_owner\": \"$OWNER\", \"repo_name\": \"$REPO_NAME\", \"ref_name\": \"$refname\"}" \
140 "http://localhost:3000/api/internal/notes/reindex" \
141 || echo "[$(date -u +%FT%TZ)] FAILED notes/reindex exit=$?"
142 ) &
143 ;;
144 refs/mnw/notes-inbox/*)
145 # The one arm that is NOT backgrounded. Builds and issue links are
146 # things the pusher learns about later; a notes merge is the answer
147 # to the push itself, and post-receive stdout is the only channel
148 # back to them. The timeouts bound what that costs: a push cannot
149 # hang on a server that is not answering.
150 echo "[$(date -u +%FT%TZ)] notes-push $OWNER/$REPO_NAME ref=$refname" >>"$LOG"
151 RESULT="$(curl -sf --connect-timeout 5 --max-time 30 -X POST \
152 -H "Authorization: Bearer __HMAC__" \
153 -H "Content-Type: application/json" \
154 -d "{\"repo_owner\": \"$OWNER\", \"repo_name\": \"$REPO_NAME\", \"ref_name\": \"$refname\"}" \
155 "http://localhost:3000/api/internal/notes/merge-inbox" 2>>"$LOG")"
156 if [ -n "$RESULT" ]; then
157 echo "notes: merged into refs/notes/${refname#refs/mnw/notes-inbox/}"
158 echo "[$(date -u +%FT%TZ)] notes-merged $RESULT" >>"$LOG"
159 else
160 # The notes are in the inbox ref either way, so nothing is lost;
161 # the next push to the namespace merges them. Say so rather than
162 # letting the push look like it did nothing.
163 echo "notes: received, merge deferred (the server did not answer)"
164 echo "[$(date -u +%FT%TZ)] FAILED notes/merge-inbox" >>"$LOG"
165 fi
166 ;;
167 esac
168 done
169 "#;
170
171 /// The `update` hook: refuse a push to a namespace MNW owns.
172 ///
173 /// `update` rather than `pre-receive` because it runs once per ref and rejects
174 /// only that one. A push carrying a branch and a stray `refs/notes/mnw/*` should
175 /// land the branch and refuse the note, not fail whole.
176 ///
177 /// This is the enforcement half of a policy the in-process write paths already
178 /// hold (`validate_note_namespace`): the browser, the JSON API and the notes
179 /// inbox all refuse the prefix. A direct push reached none of them, which left
180 /// the one door with no lock on it. The server writes these refs through gix ref
181 /// transactions, which run no hooks, so the door being locked from outside does
182 /// not lock us out.
183 ///
184 /// Static, so it carries no HMAC and needs no per-repo generation. The `mnw`
185 /// literal is `validation::RESERVED_NOTE_NAMESPACE`; a test pins the two
186 /// together, since bash cannot read the constant.
187 pub const UPDATE_HOOK: &str = r#"#!/bin/bash
188 case "$1" in
189 refs/notes/mnw|refs/notes/mnw/*)
190 echo "refs/notes/mnw/* is written by makenot.work and cannot be pushed."
191 echo "Annotate under a namespace of your own instead:"
192 echo " git push origin refs/notes/<name>:refs/mnw/notes-inbox/<name>"
193 exit 1
194 ;;
195 esac
196 exit 0
197 "#;
198
199 /// Compute a per-repo HMAC so the global token never touches disk.
200 pub fn repo_hmac(token: &str, owner: &str, repo: &str) -> String {
201 use hmac::{Hmac, KeyInit, Mac};
202 use sha2::Sha256;
203 let mut mac =
204 Hmac::<Sha256>::new_from_slice(token.as_bytes()).expect("HMAC accepts any key length");
205 mac.update(format!("{owner}:{repo}").as_bytes());
206 hex::encode(mac.finalize().into_bytes())
207 }
208
209 /// Generate the post-receive hook script with a per-repo HMAC signature.
210 pub fn post_receive_hook(token: &str, owner: &str, repo: &str) -> String {
211 let hmac = repo_hmac(token, owner, repo);
212 POST_RECEIVE_HOOK_TEMPLATE.replace("__HMAC__", &hmac)
213 }
214
215 /// Map (os, arch) to a Rust target triple.
216 pub fn rust_target(os: &str, arch: &str) -> Option<&'static str> {
217 match (os, arch) {
218 ("linux", "x86_64") => Some("x86_64-unknown-linux-gnu"),
219 ("linux", "aarch64") => Some("aarch64-unknown-linux-gnu"),
220 ("darwin", "x86_64") => Some("x86_64-apple-darwin"),
221 ("darwin", "aarch64") => Some("aarch64-apple-darwin"),
222 _ => None,
223 }
224 }
225
226 /// Get the SSH build host for a target OS from config.
227 fn build_host_for_target<'a>(config: &'a crate::config::Config, os: &str) -> Option<&'a str> {
228 match os {
229 "linux" => config.build.host_linux.as_deref(),
230 "darwin" => config.build.host_darwin.as_deref(),
231 _ => None,
232 }
233 }
234
235 /// Check for a pending build and spawn it if no build is currently running.
236 ///
237 /// Called from the scheduler loop. Non-blocking, spawns the build task and returns.
238 #[tracing::instrument(skip_all, name = "build_runner::dispatch")]
239 pub async fn dispatch_pending_build(ctx: &BuildCtx) {
240 // Recover from stale running builds (e.g. server crashed mid-build)
241 match db::builds::fail_stale_running_builds(&ctx.db, BUILD_TIMEOUT_SECS as i64).await {
242 Ok(n) if n > 0 => {
243 tracing::warn!(count = n, "marked stale running builds as failed");
244 }
245 Err(e) => {
246 tracing::error!(error = ?e, "failed to check stale builds");
247 }
248 _ => {}
249 }
250
251 let build = match db::builds::claim_pending_build(&ctx.db).await {
252 Ok(Some(b)) => b,
253 Ok(None) => return,
254 Err(e) => {
255 tracing::error!(error = ?e, "failed to claim pending build");
256 return;
257 }
258 };
259
260 let config = match db::builds::get_build_config_by_app(&ctx.db, build.app_id).await {
261 Ok(Some(c)) => c,
262 Ok(None) => {
263 tracing::error!(build_id = %build.id, "build config not found for pending build");
264 if let Err(e) = db::builds::update_build_status(
265 &ctx.db,
266 build.id,
267 BuildStatus::Failed,
268 Some("Build config not found"),
269 )
270 .await
271 {
272 tracing::error!(build_id = %build.id, error = ?e, "failed to mark build as failed (config not found)");
273 }
274 return;
275 }
276 Err(e) => {
277 tracing::error!(error = ?e, "failed to get build config");
278 return;
279 }
280 };
281
282 let ctx = ctx.clone();
283 tokio::spawn(async move {
284 run_build(&ctx, &build, &config).await;
285 // After the run rather than inside it, and from a re-read row rather
286 // than the one above: `run_build` has several exits and the note has to
287 // say what the build finally was, whichever one it took. A path added
288 // later is covered without anybody remembering to cover it.
289 annotate_build(&ctx, build.id, &config).await;
290 });
291 }
292
293 /// Mirror a finished build's outcome into `refs/notes/mnw/builds`.
294 ///
295 /// Every failure here is a warning and nothing more. The build result is in
296 /// Postgres and served from there; the note is a copy of it that the creator
297 /// keeps when they leave.
298 async fn annotate_build(ctx: &BuildCtx, build_id: db::BuildId, config: &DbBuildConfig) {
299 let build = match db::builds::get_build(&ctx.db, build_id).await {
300 Ok(Some(b)) => b,
301 Ok(None) => return,
302 Err(e) => {
303 tracing::warn!(build_id = %build_id, error = ?e, "could not re-read build to annotate it");
304 return;
305 }
306 };
307
308 let Some((owner, repo_name, repo_id)) = repo_for_build(ctx, config.repo_id).await else {
309 return;
310 };
311
312 // The tag is what triggered the build; the note goes on the commit it names,
313 // which is the page somebody actually opens. Peeling also covers an
314 // annotated tag, where the ref points at a tag object rather than a commit.
315 let Some(target) = crate::routes::git::notes_server::resolve_tag_commit(
316 &ctx.config,
317 &owner,
318 &repo_name,
319 &build.tag,
320 )
321 .await
322 else {
323 tracing::warn!(
324 build_id = %build_id, tag = %build.tag,
325 "build tag does not resolve to a commit; no note written"
326 );
327 return;
328 };
329
330 crate::routes::git::notes_server::note_build(
331 &crate::routes::git::notes_server::OwnedRepo {
332 db: &ctx.db,
333 config: &ctx.config,
334 id: repo_id,
335 owner: &owner,
336 name: &repo_name,
337 },
338 target,
339 &build,
340 &config.targets,
341 )
342 .await;
343 }
344
345 /// Resolve a build config's repo to the `(owner, name, id)` the notes writer
346 /// needs.
347 async fn repo_for_build(
348 ctx: &BuildCtx,
349 repo_id: db::GitRepoId,
350 ) -> Option<(String, String, db::GitRepoId)> {
351 let repo = match db::git_repos::get_repo_by_id(&ctx.db, repo_id).await {
352 Ok(Some(r)) => r,
353 Ok(None) => return None,
354 Err(e) => {
355 tracing::warn!(error = ?e, "could not load the repo behind a build");
356 return None;
357 }
358 };
359 let owner = match db::users::get_user_by_id(&ctx.db, repo.user_id).await {
360 Ok(Some(u)) => u,
361 Ok(None) => return None,
362 Err(e) => {
363 tracing::warn!(error = ?e, "could not load the owner of a build's repo");
364 return None;
365 }
366 };
367 Some((owner.username.to_string(), repo.name.clone(), repo.id))
368 }
369
370 fn build_failure_message(succeeded: usize, failed: usize, first_error: Option<&str>) -> String {
371 if succeeded == 0 {
372 first_error
373 .unwrap_or("no targets produced artifacts")
374 .to_string()
375 } else {
376 let total = succeeded + failed;
377 format!("partial build failure ({succeeded}/{total} targets succeeded)")
378 }
379 }
380
381 /// A successfully built target: `(target_os, arch, s3_key, signature)`.
382 type TargetArtifact = (String, String, String, String);
383 /// A failed target: `(target_str, error)`.
384 type TargetError = (String, String);
385 /// One host group's results: its artifacts and its per-target failures.
386 type GroupOutput = (Vec<TargetArtifact>, Vec<TargetError>);
387
388 /// Execute a full build: iterate targets, SSH to hosts, build, upload artifacts.
389 #[tracing::instrument(skip_all, name = "build_runner::run_build", fields(build_id = %build.id, version = %build.version))]
390 async fn run_build(ctx: &BuildCtx, build: &DbBuild, config: &DbBuildConfig) {
391 let mut artifact_keys: Vec<(String, String, String, String)> = Vec::new(); // (target_os, arch, s3_key, signature)
392 let mut failed_count: usize = 0;
393 let mut first_error: Option<String> = None;
394 // Shared across the per-host tasks, so a log line dropped inside any target
395 // still reaches the final status message.
396 let log_drops = Arc::new(AtomicUsize::new(0));
397
398 // Resolve each target to its build host up front. Synchronous failures (bad
399 // target format, no host configured) are tallied here; resolvable targets are
400 // grouped by host so independent hosts (e.g. linux vs darwin) build
401 // concurrently while same-host targets stay serial, a multi-target release no
402 // longer serializes end to end at up to 30 min/target (Perf-S2, Run 9).
403 let mut groups: Vec<(String, Vec<(String, String)>)> = Vec::new(); // host -> [(os, arch)]
404 for target_str in &config.targets {
405 let Some((target_os, arch)): Option<(&str, &str)> = target_str.split_once('/') else {
406 let msg = format!("invalid target format: {target_str}\n");
407 append_log(ctx, build.id, &msg, &log_drops).await;
408 failed_count += 1;
409 if first_error.is_none() {
410 first_error = Some(format!("invalid target format: {target_str}"));
411 }
412 continue;
413 };
414
415 let Some(host) = build_host_for_target(&ctx.config, target_os) else {
416 let msg = format!("no build host for {target_os}, skipping {target_str}\n");
417 tracing::warn!("{}", msg.trim());
418 append_log(ctx, build.id, &msg, &log_drops).await;
419 failed_count += 1;
420 if first_error.is_none() {
421 first_error = Some(format!("no build host for {target_os}"));
422 }
423 continue;
424 };
425
426 let entry = (target_os.to_string(), arch.to_string());
427 match groups.iter_mut().find(|(h, _)| h == host) {
428 Some((_, targets)) => targets.push(entry),
429 None => groups.push((host.to_string(), vec![entry])),
430 }
431 }
432
433 // One task per host; targets within a host run serially. Results are gathered
434 // by group index so the merged artifact order stays deterministic regardless
435 // of which host finishes first.
436 let group_count = groups.len();
437 let mut set: tokio::task::JoinSet<(usize, GroupOutput)> = tokio::task::JoinSet::new();
438 for (idx, (host, targets)) in groups.into_iter().enumerate() {
439 let ctx = ctx.clone();
440 let build = build.clone();
441 let config = config.clone();
442 let log_drops = Arc::clone(&log_drops);
443 set.spawn(async move {
444 let mut oks: Vec<TargetArtifact> = Vec::new();
445 let mut errs: Vec<TargetError> = Vec::new();
446 for (target_os, arch) in &targets {
447 match Box::pin(execute_target(
448 &ctx, &build, &config, &host, target_os, arch, &log_drops,
449 ))
450 .await
451 {
452 Ok((s3_key, signature)) => {
453 oks.push((target_os.clone(), arch.clone(), s3_key, signature));
454 }
455 Err(e) => errs.push((format!("{target_os}/{arch}"), e)),
456 }
457 }
458 (idx, (oks, errs))
459 });
460 }
461
462 let mut gathered: Vec<Option<GroupOutput>> = (0..group_count).map(|_| None).collect();
463 while let Some(res) = set.join_next().await {
464 match res {
465 Ok((idx, out)) => gathered[idx] = Some(out),
466 Err(e) => {
467 // A host group's task panicked; the build can't be considered
468 // complete, so fail it rather than silently dropping its targets.
469 tracing::error!(error = ?e, "build host group task panicked");
470 failed_count += 1;
471 if first_error.is_none() {
472 first_error = Some("a build host group task panicked".to_string());
473 }
474 }
475 }
476 }
477
478 for (oks, errs) in gathered.into_iter().flatten() {
479 artifact_keys.extend(oks);
480 for (target_str, e) in errs {
481 let msg = format!("target {target_str} failed: {e}\n");
482 tracing::error!("{}", msg.trim());
483 append_log(ctx, build.id, &msg, &log_drops).await;
484 failed_count += 1;
485 if first_error.is_none() {
486 first_error = Some(e);
487 }
488 }
489 }
490
491 if artifact_keys.is_empty() || failed_count > 0 {
492 let mut err_msg =
493 build_failure_message(artifact_keys.len(), failed_count, first_error.as_deref());
494 if let Some(note) = incomplete_log_note(&log_drops) {
495 err_msg.push_str(&note);
496 }
497 if let Err(e) =
498 db::builds::update_build_status(&ctx.db, build.id, BuildStatus::Failed, Some(&err_msg))
499 .await
500 {
501 tracing::error!(build_id = %build.id, error = ?e, "failed to mark build as failed");
502 }
503 if let Some(ref wam) = ctx.wam {
504 let title = format!("Build failed: {} v{}", build.tag, build.version);
505 wam.create_ticket(
506 &title,
507 Some(&err_msg),
508 "high",
509 "build-failed",
510 Some(&build.id.to_string()),
511 )
512 .await;
513 }
514 return;
515 }
516
517 // Every artifact is signed independently and served with its own signature.
518 // An unsigned artifact can never be installed (Tauri refuses an unsigned
519 // update), so if any successful target lacks a signature, fail the build
520 // loudly rather than publishing a release with a dead platform.
521 if let Some((target_os, arch, _, _)) =
522 artifact_keys.iter().find(|(_, _, _, sig)| sig.is_empty())
523 {
524 let msg = format!(
525 "build produced an unsigned artifact ({target_os}/{arch}); refusing to publish"
526 );
527 if let Err(e) =
528 db::builds::update_build_status(&ctx.db, build.id, BuildStatus::Failed, Some(&msg))
529 .await
530 {
531 tracing::error!(build_id = %build.id, error = ?e, "failed to mark build failed (missing signature)");
532 }
533 return;
534 }
535
536 // Create OTA release (only for fully successful builds)
537 let release = match db::ota::create_release(
538 &ctx.db,
539 build.app_id,
540 &build.version,
541 &format!("Automated build from tag {}", build.tag),
542 )
543 .await
544 {
545 Ok(r) => r,
546 Err(e) => {
547 let msg = format!("failed to create OTA release: {e}");
548 if let Err(e) =
549 db::builds::update_build_status(&ctx.db, build.id, BuildStatus::Failed, Some(&msg))
550 .await
551 {
552 tracing::error!(build_id = %build.id, error = ?e, "failed to mark build as failed (release creation)");
553 }
554 return;
555 }
556 };
557
558 // The app owner is the responsible identity for the artifact scans.
559 let owner_id = db::synckit::get_sync_app_by_id(&ctx.db, build.app_id)
560 .await
561 .ok()
562 .flatten()
563 .map(|app| app.creator_id);
564
565 // Record artifacts and enqueue each for malware scanning. The artifact stays
566 // `pending` (not served) until the scan clears it, same gate as the item
567 // channel.
568 for (target_os, arch, s3_key, signature) in &artifact_keys {
569 // Get file size from S3 via HEAD request (best-effort, use 0 if unavailable)
570 let file_size = if let Some(s3) = ctx.synckit_s3.as_ref() {
571 s3.object_size(s3_key).await.ok().flatten().unwrap_or(0)
572 } else {
573 0
574 };
575
576 match db::ota::create_artifact(
577 &ctx.db, release.id, target_os, arch, s3_key, file_size, signature,
578 )
579 .await
580 {
581 Ok(artifact) => {
582 if let Some(owner_id) = owner_id
583 && let Err(e) = crate::routes::ota::enqueue_ota_artifact_scan(
584 &ctx.db,
585 ctx.scanner.as_ref(),
586 artifact.id,
587 s3_key,
588 owner_id,
589 file_size,
590 )
591 .await
592 {
593 tracing::error!(artifact_id = %artifact.id, error = ?e, "failed to enqueue OTA artifact scan");
594 }
595 }
596 Err(e) => tracing::error!(error = ?e, "failed to record artifact"),
597 }
598 }
599
600 // Link build to release
601 if let Err(e) = db::builds::set_build_release(&ctx.db, build.id, release.id).await {
602 tracing::error!(build_id = %build.id, release_id = %release.id, error = ?e, "failed to link build to release");
603 }
604
605 // All targets succeeded (partial failures return early above). The build is
606 // still a success if log lines were lost, but the row says so rather than
607 // presenting a short log as the whole story.
608 let note = incomplete_log_note(&log_drops);
609 if let Err(e) =
610 db::builds::update_build_status(&ctx.db, build.id, BuildStatus::Succeeded, note.as_deref())
611 .await
612 {
613 tracing::error!(build_id = %build.id, error = ?e, "failed to mark build as succeeded");
614 }
615
616 tracing::info!(
617 build_id = %build.id,
618 version = %build.version,
619 artifacts = artifact_keys.len(),
620 "build succeeded"
621 );
622 }
623
624 /// Execute a single target: SSH to host, clone, build, upload artifact.
625 async fn execute_target(
626 ctx: &BuildCtx,
627 build: &DbBuild,
628 config: &DbBuildConfig,
629 host: &str,
630 target_os: &str,
631 arch: &str,
632 log_drops: &AtomicUsize,
633 ) -> std::result::Result<(String, String), String> {
634 let target = format!("{target_os}/{arch}");
635 let rust_triple =
636 rust_target(target_os, arch).ok_or_else(|| format!("unsupported target: {target}"))?;
637
638 // Look up repo for clone URL
639 let repo = db::git_repos::get_repo_by_id(&ctx.db, config.repo_id)
640 .await
641 .map_err(|e| format!("failed to look up repo: {e}"))?
642 .ok_or("repo not found")?;
643
644 let repo_owner = db::users::get_user_by_id(&ctx.db, repo.user_id)
645 .await
646 .map_err(|e| format!("failed to look up repo owner: {e}"))?
647 .ok_or("repo owner not found")?;
648
649 let git_root = ctx
650 .config
651 .build
652 .git_repos_path
653 .as_deref()
654 .ok_or("git_repos_path not configured")?;
655
656 let clone_path = format!("{git_root}/{}/{}.git", repo_owner.username, repo.name);
657 let build_dir = format!("/tmp/mnw-build-{}", build.id);
658
659 // Template substitution for build_command and artifact_path
660 let build_cmd = config
661 .build_command
662 .replace("{target}", rust_triple)
663 .replace("{version}", &build.version);
664 let artifact_path = config
665 .artifact_path
666 .replace("{target}", rust_triple)
667 .replace("{version}", &build.version);
668
669 // Parse build_command into a structured, fully-escaped remote command and
670 // validate artifact_path before interpolation into the shell script.
671 let remote_cmd =
672 RemoteCommand::parse(&build_cmd).map_err(|e| format!("invalid build command: {e}"))?;
673 validate_artifact_path(&artifact_path).map_err(|e| format!("invalid artifact path: {e}"))?;
674
675 // Build the SSH command sequence. Every interpolated value is shell-escaped:
676 // the git/cd/test scaffolding args via `shell_escape`, and the operator build
677 // command via `RemoteCommand::render` (each token escaped, env applied via
678 // `env`). No operator- or record-derived byte reaches the shell unescaped.
679 let remote_script = format!(
680 "set -e && \
681 git clone --depth 1 --branch {tag} {clone_path} {build_dir} && \
682 cd {build_dir} && \
683 {build_cmd} && \
684 test -f {artifact_path}",
685 tag = shell_escape(&build.tag),
686 clone_path = shell_escape(&clone_path),
687 build_dir = shell_escape(&build_dir),
688 build_cmd = remote_cmd.render(),
689 artifact_path = shell_escape(&artifact_path),
690 );
691
692 let log_msg = format!("[{target}] building on {host}...\n");
693 append_log(ctx, build.id, &log_msg, log_drops).await;
694
695 // Execute via SSH with timeout
696 let ssh_result = tokio::time::timeout(
697 Duration::from_secs(BUILD_TIMEOUT_SECS),
698 Box::pin(run_ssh_command(host, &remote_script)),
699 )
700 .await;
701
702 let output = match ssh_result {
703 Ok(Ok(output)) => output,
704 Ok(Err(e)) => {
705 // Cleanup remote build dir (best-effort)
706 Box::pin(cleanup_remote_dir(host, &build_dir)).await;
707 return Err(format!("SSH command failed: {e}"));
708 }
709 Err(_) => {
710 Box::pin(cleanup_remote_dir(host, &build_dir)).await;
711 return Err("build timed out".to_string());
712 }
713 };
714
715 append_log(
716 ctx,
717 build.id,
718 &format!("[{target}] {}\n", output.trim()),
719 log_drops,
720 )
721 .await;
722
723 // SCP artifact back and upload to S3
724 let s3_key = crate::storage::S3Client::generate_ota_artifact_key(
725 build.app_id,
726 &build.version,
727 target_os,
728 arch,
729 );
730
731 // Copy artifact from remote to local temp
732 let local_tmp = format!("/tmp/mnw-artifact-{}-{target_os}-{arch}", build.id);
733 let scp_remote_path = format!(
734 "{}/{}",
735 build_dir.trim_end_matches('/'),
736 artifact_path.trim_start_matches('/')
737 );
738 let scp_result = run_scp_download(host, &scp_remote_path, &local_tmp).await;
739
740 // Best-effort: try to download the .sig file (Tauri builds produce one)
741 let local_sig_tmp = format!("{local_tmp}.sig");
742 let scp_sig_result =
743 run_scp_download(host, &format!("{scp_remote_path}.sig"), &local_sig_tmp).await;
744
745 Box::pin(cleanup_remote_dir(host, &build_dir)).await;
746
747 if let Err(e) = scp_result {
748 // The main artifact failed, but the .sig sidecar may already be on disk
749 // from its own scp above. Clean it up before bailing so a retry loop
750 // doesn't accumulate orphaned .sig temp files (the main temp is removed
751 // unconditionally further down, but on this early return it was never
752 // created).
753 remove_temp_file(&local_sig_tmp).await;
754 return Err(format!("SCP download failed: {e}"));
755 }
756
757 // Read signature from .sig file if it was downloaded
758 let signature = if scp_sig_result.is_ok() {
759 let sig = tokio::fs::read_to_string(&local_sig_tmp)
760 .await
761 .unwrap_or_default();
762 remove_temp_file(&local_sig_tmp).await;
763 sig
764 } else {
765 String::new()
766 };
767
768 // Upload to S3 via multipart streaming from disk, the previous
769 // implementation `tokio::fs::read` → `Vec<u8>` → `upload_object` pinned
770 // the entire artifact (up to ~100 MB per build) in RAM during upload.
771 // `upload_multipart` reads the file in chunks and lets the S3 SDK do
772 // parallel part uploads, keeping memory bounded regardless of artifact
773 // size.
774 let synckit_s3 = ctx
775 .synckit_s3
776 .as_ref()
777 .ok_or("SyncKit storage not configured")?;
778
779 let upload_result = synckit_s3
780 .upload_multipart(
781 &s3_key,
782 "application/octet-stream",
783 std::path::Path::new(&local_tmp),
784 )
785 .await
786 .map_err(|e| format!("S3 multipart upload failed: {e}"));
787
788 // Always remove the local temp file, even if the upload failed, leaving
789 // it on disk fills the build runner's tmp directory across retries.
790 remove_temp_file(&local_tmp).await;
791
792 upload_result?;
793
794 if signature.is_empty() {
795 append_log(
796 ctx,
797 build.id,
798 &format!("[{target}] uploaded to {s3_key}\n"),
799 log_drops,
800 )
801 .await;
802 } else {
803 append_log(
804 ctx,
805 build.id,
806 &format!("[{target}] uploaded to {s3_key} (signed)\n"),
807 log_drops,
808 )
809 .await;
810 }
811
812 Ok((s3_key.into_string(), signature))
813 }
814
815 /// Private key for build SSH connections.
816 ///
817 /// Passed with an explicit `-i` when present. Without it ssh falls back to the
818 /// service user's `~/.ssh`, which the unit's `ProtectHome=yes` presents as
819 /// empty, so an unprovisioned identity is a build pipeline that authenticates
820 /// against nothing. Kept beside [`BUILD_SSH_KNOWN_HOSTS`] under `/etc/mnw`,
821 /// which the sandbox leaves readable.
822 const BUILD_SSH_IDENTITY: &str = "/etc/mnw/build_ssh_key";
823
824 /// SSH identity options. Returned owned so callers can push them straight into
825 /// an argv vector. Shared by [`run_ssh_command`] and [`run_scp_download`] so
826 /// the two can't drift.
827 fn ssh_identity_args() -> Vec<String> {
828 if std::path::Path::new(BUILD_SSH_IDENTITY).exists() {
829 vec![
830 "-o".into(),
831 "IdentitiesOnly=yes".into(),
832 "-i".into(),
833 BUILD_SSH_IDENTITY.into(),
834 ]
835 } else {
836 tracing::warn!(
837 identity = BUILD_SSH_IDENTITY,
838 "build SSH identity absent; falling back to the service user's ~/.ssh, which \
839 ProtectHome=yes hides. Provision {BUILD_SSH_IDENTITY} to authenticate builds \
840 under the sandbox",
841 );
842 Vec::new()
843 }
844 }
845
846 /// Path to a known_hosts file for build SSH connections.
847 /// When present, StrictHostKeyChecking=yes is used (pinned keys).
848 /// When absent, StrictHostKeyChecking=accept-new (trust on first use).
849 const BUILD_SSH_KNOWN_HOSTS: &str = "/etc/mnw/known_hosts";
850
851 /// SSH/SCP host-key verification options. Pins host keys
852 /// (`StrictHostKeyChecking=yes`) when the known_hosts file is present; otherwise
853 /// falls back to trust-on-first-use AND logs a warning, so an unprovisioned
854 /// known_hosts is visible in the logs rather than silently accepting any host
855 /// key. Returned owned so callers can push them straight into an argv vector.
856 /// Shared by [`run_ssh_command`] and [`run_scp_download`] so the two can't drift.
857 fn ssh_host_key_args() -> Vec<String> {
858 if std::path::Path::new(BUILD_SSH_KNOWN_HOSTS).exists() {
859 vec![
860 "-o".into(),
861 "StrictHostKeyChecking=yes".into(),
862 "-o".into(),
863 format!("UserKnownHostsFile={BUILD_SSH_KNOWN_HOSTS}"),
864 ]
865 } else {
866 tracing::warn!(
867 known_hosts = BUILD_SSH_KNOWN_HOSTS,
868 "build SSH known_hosts file absent; falling back to trust-on-first-use (accept-new). \
869 Provision {BUILD_SSH_KNOWN_HOSTS} to pin build-host keys",
870 );
871 vec!["-o".into(), "StrictHostKeyChecking=accept-new".into()]
872 }
873 }
874
875 /// Run a command on a remote host via SSH.
876 async fn run_ssh_command(host: &str, command: &str) -> std::result::Result<String, String> {
877 let mut args: Vec<String> = vec![
878 "-o".into(),
879 "ConnectTimeout=10".into(),
880 "-o".into(),
881 "BatchMode=yes".into(),
882 ];
883 args.extend(ssh_host_key_args());
884 args.extend(ssh_identity_args());
885 args.push(host.to_string());
886 args.push(command.to_string());
887 let mut child = tokio::process::Command::new("ssh")
888 .args(&args)
889 .stdin(std::process::Stdio::null())
890 .stdout(std::process::Stdio::piped())
891 .stderr(std::process::Stdio::piped())
892 // Kill the ssh process if this future is dropped (e.g. the 30-min build
893 // timeout fires): otherwise the dropped future leaves ssh, and the
894 // remote build it drives, running orphaned (ultra-fuzz Run 11 Perf).
895 .kill_on_drop(true)
896 .spawn()
897 .map_err(|e| format!("failed to spawn ssh: {e}"))?;
898
899 let stdout_pipe = child.stdout.take().expect("stdout piped");
900 let stderr_pipe = child.stderr.take().expect("stderr piped");
901
902 // Stream both pipes with a per-stream cap instead of Command::output(), which
903 // would buffer all of a chatty 30-min build's output in RAM before the log
904 // cap is ever applied. read_capped keeps draining past the cap (so the child
905 // never blocks on a full pipe) but only retains the first BUILD_MAX_LOG_BYTES
906 // (ultra-fuzz Run 10 Perf S2). Reading both concurrently with wait() avoids
907 // the single-pipe-fills-and-deadlocks hazard.
908 let (stdout_buf, stderr_buf, status) = tokio::join!(
909 read_capped(stdout_pipe, BUILD_MAX_LOG_BYTES),
910 read_capped(stderr_pipe, BUILD_MAX_LOG_BYTES),
911 child.wait(),
912 );
913 let status = status.map_err(|e| format!("ssh wait failed: {e}"))?;
914
915 if status.success() {
916 Ok(stdout_buf)
917 } else {
918 Err(format!(
919 "exit code {}: {}",
920 status.code().unwrap_or(-1),
921 stderr_buf.trim()
922 ))
923 }
924 }
925
926 /// Drain an async reader to completion but retain only the first `cap` bytes.
927 /// Draining past the cap keeps the child process from blocking on a full pipe
928 /// buffer; retaining only `cap` bounds memory regardless of output volume.
929 async fn read_capped<R>(mut reader: R, cap: usize) -> String
930 where
931 R: tokio::io::AsyncRead + Unpin,
932 {
933 use tokio::io::AsyncReadExt;
934 let mut kept = Vec::new();
935 let mut chunk = [0u8; 8192];
936 loop {
937 match reader.read(&mut chunk).await {
938 Ok(0) | Err(_) => break,
939 Ok(n) => {
940 if kept.len() < cap {
941 let take = n.min(cap - kept.len());
942 kept.extend_from_slice(&chunk[..take]);
943 }
944 }
945 }
946 }
947 String::from_utf8_lossy(&kept).into_owned()
948 }
949
950 /// Download a file from a remote host via SCP.
951 async fn run_scp_download(
952 host: &str,
953 remote_path: &str,
954 local_path: &str,
955 ) -> std::result::Result<(), String> {
956 let remote = format!("{host}:{remote_path}");
957 let mut args: Vec<String> = vec![
958 "-o".into(),
959 "ConnectTimeout=10".into(),
960 "-o".into(),
961 "BatchMode=yes".into(),
962 ];
963 args.extend(ssh_host_key_args());
964 args.extend(ssh_identity_args());
965 args.push(remote);
966 args.push(local_path.to_string());
967 let scp = tokio::process::Command::new("scp")
968 .args(&args)
969 // Kill scp if this future is dropped (build timeout, or the transfer
970 // timeout below) rather than leaving an orphaned transfer running
971 // (ultra-fuzz Run 11 Perf).
972 .kill_on_drop(true)
973 .output();
974 // Bound the whole transfer: a build host that stalls mid-stream would
975 // otherwise hang the build task and pin the single build slot forever
976 // (Run 15 Resilience). ConnectTimeout only covers connection setup.
977 let output =
978 match tokio::time::timeout(Duration::from_secs(SCP_TRANSFER_TIMEOUT_SECS), scp).await {
979 Ok(r) => r.map_err(|e| format!("failed to spawn scp: {e}"))?,
980 Err(_) => return Err("scp transfer timed out".to_string()),
981 };
982
983 if output.status.success() {
984 Ok(())
985 } else {
986 let stderr = String::from_utf8_lossy(&output.stderr);
987 Err(format!(
988 "exit code {}: {}",
989 output.status.code().unwrap_or(-1),
990 stderr.trim()
991 ))
992 }
993 }
994
995 /// Append to build log, respecting the max log size.
996 ///
997 /// Probes `octet_length(log)` instead of fetching the whole row (the log
998 /// column tops out at 5 MiB and is read on every line append).
999 /// Remove a build temp file, naming it if it could not be removed.
1000 ///
1001 /// The caller is either bailing out or done with the file, so there is nothing
1002 /// to propagate to; what matters is that a temp file left behind is visible,
1003 /// since these accumulate across retries and fill the runner's tmp directory.
1004 /// A missing file is the expected case on the error paths and is not a failure.
1005 async fn remove_temp_file(path: &str) {
1006 match tokio::fs::remove_file(path).await {
1007 Ok(()) => {}
1008 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1009 Err(e) => tracing::warn!(path, error = %e, "failed to remove build temp file"),
1010 }
1011 }
1012
1013 /// A one-line note for the build row when log lines were lost, or `None` when
1014 /// the log is whole.
1015 fn incomplete_log_note(drops: &AtomicUsize) -> Option<String> {
1016 match drops.load(Ordering::Relaxed) {
1017 0 => None,
1018 n => Some(format!(
1019 " (build log incomplete: {n} line(s) could not be stored)"
1020 )),
1021 }
1022 }
1023
1024 /// Append a build-log line, counting the line against `drops` if it could not
1025 /// be stored.
1026 ///
1027 /// Nothing useful can be done at the call site (the line is already produced
1028 /// and the build is mid-flight), but a build whose log silently lost lines must
1029 /// not finish looking clean: `run_build` reads the counter and says so on the
1030 /// build row. That is the difference between "the build printed nothing here"
1031 /// and "we failed to write down what it printed".
1032 async fn append_log(ctx: &BuildCtx, build_id: db::BuildId, line: &str, drops: &AtomicUsize) {
1033 if let Err(e) = append_log_bounded(ctx, build_id, line).await {
1034 drops.fetch_add(1, Ordering::Relaxed);
1035 tracing::error!(build_id = %build_id, error = ?e, "build log append failed; build log is incomplete");
1036 }
1037 }
1038
1039 async fn append_log_bounded(
1040 ctx: &BuildCtx,
1041 build_id: db::BuildId,
1042 line: &str,
1043 ) -> crate::error::Result<()> {
1044 const TRUNCATED: &str = "[log truncated]\n";
1045 if let Some((current_len, already_truncated)) =
1046 db::builds::get_build_log_size(&ctx.db, build_id, TRUNCATED).await?
1047 && (current_len as usize) + line.len() > BUILD_MAX_LOG_BYTES
1048 {
1049 if !already_truncated {
1050 tracing::warn!(build_id = %build_id, "Build log exceeded {} bytes, truncating", BUILD_MAX_LOG_BYTES);
1051 db::builds::append_build_log(&ctx.db, build_id, TRUNCATED).await?;
1052 }
1053 return Ok(());
1054 }
1055 let sanitized = strip_ansi_escapes(line);
1056 db::builds::append_build_log(&ctx.db, build_id, &sanitized).await
1057 }
1058
1059 /// Strip ANSI escape sequences (e.g. color codes) from build output before
1060 /// storing it in the database.
1061 fn strip_ansi_escapes(s: &str) -> String {
1062 let mut result = String::with_capacity(s.len());
1063 let mut chars = s.chars();
1064 while let Some(c) = chars.next() {
1065 if c == '\x1b' {
1066 // Consume the next char; if it's '[' we have a CSI sequence
1067 // and we skip parameter/intermediate bytes up to the final byte.
1068 // Otherwise (OSC / other sequences) just drop the two-char escape.
1069 if let Some(next) = chars.next()
1070 && next == '['
1071 {
1072 // CSI sequence: skip until we hit a letter (0x40..=0x7E).
1073 for tail in chars.by_ref() {
1074 if tail.is_ascii_alphabetic() {
1075 break;
1076 }
1077 }
1078 }
1079 } else {
1080 result.push(c);
1081 }
1082 }
1083 result
1084 }
1085
1086 /// A build command parsed into a shell-injection-proof structured form.
1087 ///
1088 /// The operator-configured `build_command` is a single string (e.g.
1089 /// `RUSTFLAGS=--cfg cargo build --release`). Rather than interpolate it raw into
1090 /// the remote `sh -c` script, where its safety rested entirely on a
1091 /// metacharacter denylist, one added allowed character away from reopening
1092 /// injection, it is tokenised into leading `NAME=VALUE` environment
1093 /// assignments followed by a program and its arguments. `render` emits every
1094 /// element individually shell-escaped, applying assignments via `env`, so no
1095 /// operator byte can break out of its shell word. Shell injection is
1096 /// structurally impossible here, not denylist-gated; the per-token charset
1097 /// check below is defense-in-depth, no longer the sole guard.
1098 struct RemoteCommand {
1099 /// Leading `NAME=VALUE` assignments, applied via `env` before the program.
1100 assignments: Vec<String>,
1101 /// The program to execute.
1102 program: String,
1103 /// The program's arguments.
1104 args: Vec<String>,
1105 }
1106
1107 impl RemoteCommand {
1108 /// Parse a (template-substituted) build command string into its structured
1109 /// form. Tokenised on ASCII whitespace; leading `NAME=VALUE` tokens become
1110 /// env assignments, the first remaining token is the program, the rest are
1111 /// arguments. Each token is charset-validated as defense-in-depth.
1112 fn parse(cmd: &str) -> std::result::Result<Self, String> {
1113 if cmd.len() > 1024 {
1114 return Err("build command too long (max 1024 chars)".to_string());
1115 }
1116 let tokens: Vec<&str> = cmd.split_whitespace().collect();
1117 if tokens.is_empty() {
1118 return Err("build command is empty".to_string());
1119 }
1120 for tok in &tokens {
1121 validate_command_token(tok)?;
1122 }
1123
1124 let mut assignments = Vec::new();
1125 let mut rest = tokens.as_slice();
1126 while let Some((first, tail)) = rest.split_first() {
1127 if is_env_assignment(first) {
1128 assignments.push((*first).to_string());
1129 rest = tail;
1130 } else {
1131 break;
1132 }
1133 }
1134
1135 let (program, args) = rest.split_first().ok_or_else(|| {
1136 "build command has environment assignments but no program".to_string()
1137 })?;
1138
1139 Ok(Self {
1140 assignments,
1141 program: (*program).to_string(),
1142 args: args.iter().map(|s| (*s).to_string()).collect(),
1143 })
1144 }
1145
1146 /// Render as a single shell command line with every element escaped. Safe to
1147 /// interpolate into a larger `sh -c` script: no element can inject.
1148 fn render(&self) -> String {
1149 let mut parts = Vec::with_capacity(self.assignments.len() + self.args.len() + 2);
1150 if !self.assignments.is_empty() {
1151 parts.push("env".to_string());
1152 parts.extend(self.assignments.iter().map(|a| shell_escape(a)));
1153 }
1154 parts.push(shell_escape(&self.program));
1155 parts.extend(self.args.iter().map(|a| shell_escape(a)));
1156 parts.join(" ")
1157 }
1158 }
1159
1160 /// True if a token is a `NAME=VALUE` environment assignment (a valid shell
1161 /// identifier before the first `=`).
1162 fn is_env_assignment(tok: &str) -> bool {
1163 match tok.split_once('=') {
1164 Some((name, _)) => {
1165 !name.is_empty()
1166 && name.chars().enumerate().all(|(i, c)| {
1167 c == '_' || c.is_ascii_alphabetic() || (i > 0 && c.is_ascii_digit())
1168 })
1169 }
1170 None => false,
1171 }
1172 }
1173
1174 /// Validate a single build-command token's charset. Rejects shell
1175 /// metacharacters and control characters as defense-in-depth; the rendered
1176 /// command escapes every token regardless, so this is not the sole guard.
1177 fn validate_command_token(tok: &str) -> std::result::Result<(), String> {
1178 for (i, c) in tok.chars().enumerate() {
1179 match c {
1180 'a'..='z' | 'A'..='Z' | '0'..='9' => {}
1181 '-' | '_' | '.' | '/' | '=' | ':' | ',' | '+' | '{' | '}' | '@' => {}
1182 _ => {
1183 return Err(format!(
1184 "character '{}' at position {} in token '{}' is not allowed",
1185 c.escape_default(),
1186 i,
1187 tok.escape_default(),
1188 ));
1189 }
1190 }
1191 }
1192 Ok(())
1193 }
1194
1195 /// Validate a build command for shell safety at config-write time. Validation is
1196 /// exactly "parses into a [`RemoteCommand`]", the same parser the executor uses
1197 ///, so a stored command that validates here can never fail to render safely.
1198 pub fn validate_build_command(cmd: &str) -> std::result::Result<(), String> {
1199 RemoteCommand::parse(cmd).map(|_| ())
1200 }
1201
1202 /// Validate an artifact path for shell and path safety.
1203 ///
1204 /// Must be a relative path with no shell metacharacters or path traversal.
1205 pub fn validate_artifact_path(path: &str) -> std::result::Result<(), String> {
1206 if path.is_empty() {
1207 return Err("artifact path is empty".to_string());
1208 }
1209 if path.len() > 512 {
1210 return Err("artifact path too long (max 512 chars)".to_string());
1211 }
1212 if path.starts_with('/') {
1213 return Err("artifact path must be relative".to_string());
1214 }
1215 if path.contains("..") {
1216 return Err("artifact path must not contain '..'".to_string());
1217 }
1218 for (i, c) in path.chars().enumerate() {
1219 match c {
1220 'a'..='z' | 'A'..='Z' | '0'..='9' => {}
1221 '-' | '_' | '.' | '/' | '{' | '}' | '+' => {}
1222 ';' | '&' | '|' | '$' | '`' | '(' | ')' | '<' | '>' | '!' | '\\' | '\'' | '"' | ' '
1223 | '\n' | '\r' | '\0' => {
1224 return Err(format!(
1225 "character '{}' at position {} is not allowed in artifact path",
1226 c.escape_default(),
1227 i
1228 ));
1229 }
1230 _ => {
1231 return Err(format!(
1232 "unexpected character '{}' at position {} is not allowed in artifact path",
1233 c.escape_default(),
1234 i
1235 ));
1236 }
1237 }
1238 }
1239 Ok(())
1240 }
1241
1242 /// Escape a string for safe use in a shell command.
1243 fn shell_escape(s: &str) -> String {
1244 format!("'{}'", s.replace('\'', "'\\''"))
1245 }
1246
1247 #[cfg(test)]
1248 mod tests {
1249 use super::*;
1250
1251 #[test]
1252 fn the_update_hook_guards_the_namespace_validation_reserves() {
1253 // Bash cannot read a Rust constant, so the literal in the hook is a
1254 // copy. Renaming the reserved prefix without editing the hook would
1255 // leave the new one pushable and the old one locked, which is the
1256 // failure this pins: two doors, one policy.
1257 let reserved = crate::validation::RESERVED_NOTE_NAMESPACE;
1258 assert!(
1259 UPDATE_HOOK.contains(&format!("refs/notes/{reserved}|refs/notes/{reserved}/*")),
1260 "the update hook does not guard refs/notes/{reserved}/*:\n{UPDATE_HOOK}"
1261 );
1262 // The bare prefix and the subtree are separate patterns in a glob, and
1263 // matching only the subtree would leave `refs/notes/mnw` itself open.
1264 assert!(UPDATE_HOOK.contains("exit 1"), "{UPDATE_HOOK}");
1265 }
1266
1267 #[tokio::test]
1268 async fn read_capped_truncates_to_cap() {
1269 // 10k bytes through a 4k cap retains exactly 4k (the rest is drained and
1270 // discarded so the child never blocks on a full pipe).
1271 let data = vec![b'x'; 10_000];
1272 let out = read_capped(&data[..], 4096).await;
1273 assert_eq!(out.len(), 4096);
1274 }
1275
1276 #[tokio::test]
1277 async fn read_capped_returns_all_when_under_cap() {
1278 let out = read_capped(&b"hello world"[..], 4096).await;
1279 assert_eq!(out, "hello world");
1280 }
1281
1282 #[test]
1283 fn build_failure_message_partial() {
1284 assert_eq!(
1285 build_failure_message(1, 2, Some("boom")),
1286 "partial build failure (1/3 targets succeeded)"
1287 );
1288 assert_eq!(
1289 build_failure_message(2, 1, Some("boom")),
1290 "partial build failure (2/3 targets succeeded)"
1291 );
1292 }
1293
1294 #[test]
1295 fn build_failure_message_total_failure_uses_first_error() {
1296 assert_eq!(build_failure_message(0, 3, Some("ssh down")), "ssh down");
1297 assert_eq!(
1298 build_failure_message(0, 0, None),
1299 "no targets produced artifacts"
1300 );
1301 }
1302
1303 #[test]
1304 fn rust_target_mapping() {
1305 assert_eq!(
1306 rust_target("linux", "x86_64"),
1307 Some("x86_64-unknown-linux-gnu")
1308 );
1309 assert_eq!(
1310 rust_target("linux", "aarch64"),
1311 Some("aarch64-unknown-linux-gnu")
1312 );
1313 assert_eq!(rust_target("darwin", "x86_64"), Some("x86_64-apple-darwin"));
1314 assert_eq!(
1315 rust_target("darwin", "aarch64"),
1316 Some("aarch64-apple-darwin")
1317 );
1318 assert_eq!(rust_target("windows", "x86_64"), None);
1319 }
1320
1321 #[test]
1322 fn hook_template_contains_hmac_not_raw_token() {
1323 let hook = post_receive_hook("secret-token-123", "alice", "myrepo");
1324 let expected_hmac = repo_hmac("secret-token-123", "alice", "myrepo");
1325 assert!(
1326 hook.contains(&expected_hmac),
1327 "hook should contain per-repo HMAC"
1328 );
1329 assert!(
1330 !hook.contains("secret-token-123"),
1331 "hook must not contain raw token"
1332 );
1333 assert!(!hook.contains("__HMAC__"), "placeholder should be replaced");
1334 assert!(hook.contains("/api/internal/builds/trigger"));
1335 }
1336
1337 /// The two notes arms answer different refs and must not be confused for
1338 /// each other: an inbox push is merged and answered synchronously, a notes
1339 /// push is only indexed. A `case` pattern that caught both would either
1340 /// merge a ref that is already the namespace or leave a push unindexed.
1341 #[test]
1342 fn the_hook_indexes_a_notes_push_and_merges_an_inbox_push() {
1343 let hook = post_receive_hook("secret-token-123", "alice", "myrepo");
1344 assert!(hook.contains("/api/internal/notes/reindex"));
1345 assert!(hook.contains("/api/internal/notes/merge-inbox"));
1346 assert!(hook.contains("refs/notes/*)"));
1347 assert!(hook.contains("refs/mnw/notes-inbox/*)"));
1348 // The inbox lives under refs/mnw/, so nothing an inbox push does can
1349 // fall into the indexing arm. `notes_inbox` pins that prefix itself.
1350 assert!(!"refs/mnw/notes-inbox/commits".starts_with("refs/notes/"));
1351 }
1352
1353 /// Fixed vector, duplicated in mnw-cli's `repo_hmac` test. mnw-cli installs
1354 /// hooks for repos it auto-creates over SSH, and this endpoint verifies
1355 /// them; if either side's derivation moves, both tests have to move
1356 /// together or those pushes stop triggering builds.
1357 #[test]
1358 fn repo_hmac_matches_mnw_cli_vector() {
1359 assert_eq!(
1360 repo_hmac("test-token", "max", "repo"),
1361 "198b4a4f542c0c27c4ca333030dfe09fe670aa44bae34d7a586481bb13fd9eb0"
1362 );
1363 }
1364
1365 #[test]
1366 fn repo_hmac_differs_per_repo() {
1367 let h1 = repo_hmac("token", "alice", "repo-a");
1368 let h2 = repo_hmac("token", "alice", "repo-b");
1369 assert_ne!(h1, h2, "different repos should produce different HMACs");
1370 }
1371
1372 #[test]
1373 fn shell_escape_basic() {
1374 assert_eq!(shell_escape("hello"), "'hello'");
1375 assert_eq!(shell_escape("it's"), "'it'\\''s'");
1376 }
1377
1378 #[test]
1379 fn validate_build_command_accepts_safe_commands() {
1380 assert!(
1381 validate_build_command("cargo build --release --target x86_64-unknown-linux-gnu")
1382 .is_ok()
1383 );
1384 assert!(validate_build_command("make -j4").is_ok());
1385 assert!(validate_build_command("RUSTFLAGS=--cfg tokio_unstable cargo build").is_ok());
1386 }
1387
1388 #[test]
1389 fn validate_build_command_rejects_injection() {
1390 assert!(validate_build_command("cargo build; curl evil.com").is_err());
1391 assert!(validate_build_command("cargo build && rm -rf /").is_err());
1392 assert!(validate_build_command("cargo build | tee log").is_err());
1393 assert!(validate_build_command("$(whoami)").is_err());
1394 assert!(validate_build_command("`whoami`").is_err());
1395 assert!(validate_build_command("cargo build > /dev/null").is_err());
1396 assert!(validate_build_command("").is_err());
1397 assert!(
1398 validate_build_command(" ").is_err(),
1399 "whitespace-only has no program"
1400 );
1401 assert!(
1402 validate_build_command("FOO=bar").is_err(),
1403 "assignment with no program"
1404 );
1405 }
1406
1407 #[test]
1408 fn remote_command_parse_separates_env_program_args() {
1409 let c = RemoteCommand::parse("cargo build --release").unwrap();
1410 assert!(c.assignments.is_empty());
1411 assert_eq!(c.program, "cargo");
1412 assert_eq!(c.args, vec!["build", "--release"]);
1413
1414 let c = RemoteCommand::parse("RUSTFLAGS=--cfg CARGO_INCREMENTAL=0 cargo build").unwrap();
1415 assert_eq!(
1416 c.assignments,
1417 vec!["RUSTFLAGS=--cfg", "CARGO_INCREMENTAL=0"]
1418 );
1419 assert_eq!(c.program, "cargo");
1420 assert_eq!(c.args, vec!["build"]);
1421 }
1422
1423 #[test]
1424 fn remote_command_render_escapes_every_token() {
1425 // Plain command: each token individually single-quoted.
1426 let c = RemoteCommand::parse("cargo build --release").unwrap();
1427 assert_eq!(c.render(), "'cargo' 'build' '--release'");
1428
1429 // Env prefix: applied via `env`, each element escaped.
1430 let c = RemoteCommand::parse("RUSTFLAGS=--cfg cargo build").unwrap();
1431 assert_eq!(c.render(), "env 'RUSTFLAGS=--cfg' 'cargo' 'build'");
1432 }
1433
1434 #[test]
1435 fn is_env_assignment_recognizes_valid_identifiers_only() {
1436 assert!(is_env_assignment("FOO=bar"));
1437 assert!(is_env_assignment("_X1=y"));
1438 assert!(is_env_assignment("A=")); // empty value is a valid assignment
1439 assert!(
1440 !is_env_assignment("1FOO=bar"),
1441 "identifier can't start with a digit"
1442 );
1443 assert!(!is_env_assignment("cargo"), "no '='");
1444 assert!(!is_env_assignment("--target=x"), "not a shell identifier");
1445 }
1446
1447 #[test]
1448 fn render_defuses_would_be_injection_even_if_charset_bypassed() {
1449 // Construct a RemoteCommand directly with a hostile arg (bypassing the
1450 // token charset check) to prove render() is the real guard: the shell
1451 // sees a single quoted word, not a command separator.
1452 let c = RemoteCommand {
1453 assignments: vec![],
1454 program: "cargo".to_string(),
1455 args: vec!["build; rm -rf /".to_string()],
1456 };
1457 assert_eq!(c.render(), "'cargo' 'build; rm -rf /'");
1458 }
1459
1460 #[test]
1461 fn validate_artifact_path_accepts_safe_paths() {
1462 assert!(validate_artifact_path("target/x86_64-unknown-linux-gnu/release/myapp").is_ok());
1463 assert!(validate_artifact_path("dist/app-v0.1.0.tar.gz").is_ok());
1464 }
1465
1466 #[test]
1467 fn validate_artifact_path_rejects_unsafe() {
1468 assert!(validate_artifact_path("/etc/passwd").is_err());
1469 assert!(validate_artifact_path("../../../etc/passwd").is_err());
1470 assert!(validate_artifact_path("path with spaces").is_err());
1471 assert!(validate_artifact_path("$(whoami)").is_err());
1472 assert!(validate_artifact_path("").is_err());
1473 }
1474 }
1475