Skip to main content

max / makenotwork

56.1 KB · 1442 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 /// Path to a known_hosts file for build SSH connections.
816 /// When present, StrictHostKeyChecking=yes is used (pinned keys).
817 /// When absent, StrictHostKeyChecking=accept-new (trust on first use).
818 const BUILD_SSH_KNOWN_HOSTS: &str = "/etc/mnw/known_hosts";
819
820 /// SSH/SCP host-key verification options. Pins host keys
821 /// (`StrictHostKeyChecking=yes`) when the known_hosts file is present; otherwise
822 /// falls back to trust-on-first-use AND logs a warning, so an unprovisioned
823 /// known_hosts is visible in the logs rather than silently accepting any host
824 /// key. Returned owned so callers can push them straight into an argv vector.
825 /// Shared by [`run_ssh_command`] and [`run_scp_download`] so the two can't drift.
826 fn ssh_host_key_args() -> Vec<String> {
827 if std::path::Path::new(BUILD_SSH_KNOWN_HOSTS).exists() {
828 vec![
829 "-o".into(),
830 "StrictHostKeyChecking=yes".into(),
831 "-o".into(),
832 format!("UserKnownHostsFile={BUILD_SSH_KNOWN_HOSTS}"),
833 ]
834 } else {
835 tracing::warn!(
836 known_hosts = BUILD_SSH_KNOWN_HOSTS,
837 "build SSH known_hosts file absent; falling back to trust-on-first-use (accept-new). \
838 Provision {BUILD_SSH_KNOWN_HOSTS} to pin build-host keys",
839 );
840 vec!["-o".into(), "StrictHostKeyChecking=accept-new".into()]
841 }
842 }
843
844 /// Run a command on a remote host via SSH.
845 async fn run_ssh_command(host: &str, command: &str) -> std::result::Result<String, String> {
846 let mut args: Vec<String> = vec![
847 "-o".into(),
848 "ConnectTimeout=10".into(),
849 "-o".into(),
850 "BatchMode=yes".into(),
851 ];
852 args.extend(ssh_host_key_args());
853 args.push(host.to_string());
854 args.push(command.to_string());
855 let mut child = tokio::process::Command::new("ssh")
856 .args(&args)
857 .stdin(std::process::Stdio::null())
858 .stdout(std::process::Stdio::piped())
859 .stderr(std::process::Stdio::piped())
860 // Kill the ssh process if this future is dropped (e.g. the 30-min build
861 // timeout fires): otherwise the dropped future leaves ssh, and the
862 // remote build it drives, running orphaned (ultra-fuzz Run 11 Perf).
863 .kill_on_drop(true)
864 .spawn()
865 .map_err(|e| format!("failed to spawn ssh: {e}"))?;
866
867 let stdout_pipe = child.stdout.take().expect("stdout piped");
868 let stderr_pipe = child.stderr.take().expect("stderr piped");
869
870 // Stream both pipes with a per-stream cap instead of Command::output(), which
871 // would buffer all of a chatty 30-min build's output in RAM before the log
872 // cap is ever applied. read_capped keeps draining past the cap (so the child
873 // never blocks on a full pipe) but only retains the first BUILD_MAX_LOG_BYTES
874 // (ultra-fuzz Run 10 Perf S2). Reading both concurrently with wait() avoids
875 // the single-pipe-fills-and-deadlocks hazard.
876 let (stdout_buf, stderr_buf, status) = tokio::join!(
877 read_capped(stdout_pipe, BUILD_MAX_LOG_BYTES),
878 read_capped(stderr_pipe, BUILD_MAX_LOG_BYTES),
879 child.wait(),
880 );
881 let status = status.map_err(|e| format!("ssh wait failed: {e}"))?;
882
883 if status.success() {
884 Ok(stdout_buf)
885 } else {
886 Err(format!(
887 "exit code {}: {}",
888 status.code().unwrap_or(-1),
889 stderr_buf.trim()
890 ))
891 }
892 }
893
894 /// Drain an async reader to completion but retain only the first `cap` bytes.
895 /// Draining past the cap keeps the child process from blocking on a full pipe
896 /// buffer; retaining only `cap` bounds memory regardless of output volume.
897 async fn read_capped<R>(mut reader: R, cap: usize) -> String
898 where
899 R: tokio::io::AsyncRead + Unpin,
900 {
901 use tokio::io::AsyncReadExt;
902 let mut kept = Vec::new();
903 let mut chunk = [0u8; 8192];
904 loop {
905 match reader.read(&mut chunk).await {
906 Ok(0) | Err(_) => break,
907 Ok(n) => {
908 if kept.len() < cap {
909 let take = n.min(cap - kept.len());
910 kept.extend_from_slice(&chunk[..take]);
911 }
912 }
913 }
914 }
915 String::from_utf8_lossy(&kept).into_owned()
916 }
917
918 /// Download a file from a remote host via SCP.
919 async fn run_scp_download(
920 host: &str,
921 remote_path: &str,
922 local_path: &str,
923 ) -> std::result::Result<(), String> {
924 let remote = format!("{host}:{remote_path}");
925 let mut args: Vec<String> = vec![
926 "-o".into(),
927 "ConnectTimeout=10".into(),
928 "-o".into(),
929 "BatchMode=yes".into(),
930 ];
931 args.extend(ssh_host_key_args());
932 args.push(remote);
933 args.push(local_path.to_string());
934 let scp = tokio::process::Command::new("scp")
935 .args(&args)
936 // Kill scp if this future is dropped (build timeout, or the transfer
937 // timeout below) rather than leaving an orphaned transfer running
938 // (ultra-fuzz Run 11 Perf).
939 .kill_on_drop(true)
940 .output();
941 // Bound the whole transfer: a build host that stalls mid-stream would
942 // otherwise hang the build task and pin the single build slot forever
943 // (Run 15 Resilience). ConnectTimeout only covers connection setup.
944 let output =
945 match tokio::time::timeout(Duration::from_secs(SCP_TRANSFER_TIMEOUT_SECS), scp).await {
946 Ok(r) => r.map_err(|e| format!("failed to spawn scp: {e}"))?,
947 Err(_) => return Err("scp transfer timed out".to_string()),
948 };
949
950 if output.status.success() {
951 Ok(())
952 } else {
953 let stderr = String::from_utf8_lossy(&output.stderr);
954 Err(format!(
955 "exit code {}: {}",
956 output.status.code().unwrap_or(-1),
957 stderr.trim()
958 ))
959 }
960 }
961
962 /// Append to build log, respecting the max log size.
963 ///
964 /// Probes `octet_length(log)` instead of fetching the whole row (the log
965 /// column tops out at 5 MiB and is read on every line append).
966 /// Remove a build temp file, naming it if it could not be removed.
967 ///
968 /// The caller is either bailing out or done with the file, so there is nothing
969 /// to propagate to; what matters is that a temp file left behind is visible,
970 /// since these accumulate across retries and fill the runner's tmp directory.
971 /// A missing file is the expected case on the error paths and is not a failure.
972 async fn remove_temp_file(path: &str) {
973 match tokio::fs::remove_file(path).await {
974 Ok(()) => {}
975 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
976 Err(e) => tracing::warn!(path, error = %e, "failed to remove build temp file"),
977 }
978 }
979
980 /// A one-line note for the build row when log lines were lost, or `None` when
981 /// the log is whole.
982 fn incomplete_log_note(drops: &AtomicUsize) -> Option<String> {
983 match drops.load(Ordering::Relaxed) {
984 0 => None,
985 n => Some(format!(
986 " (build log incomplete: {n} line(s) could not be stored)"
987 )),
988 }
989 }
990
991 /// Append a build-log line, counting the line against `drops` if it could not
992 /// be stored.
993 ///
994 /// Nothing useful can be done at the call site (the line is already produced
995 /// and the build is mid-flight), but a build whose log silently lost lines must
996 /// not finish looking clean: `run_build` reads the counter and says so on the
997 /// build row. That is the difference between "the build printed nothing here"
998 /// and "we failed to write down what it printed".
999 async fn append_log(ctx: &BuildCtx, build_id: db::BuildId, line: &str, drops: &AtomicUsize) {
1000 if let Err(e) = append_log_bounded(ctx, build_id, line).await {
1001 drops.fetch_add(1, Ordering::Relaxed);
1002 tracing::error!(build_id = %build_id, error = ?e, "build log append failed; build log is incomplete");
1003 }
1004 }
1005
1006 async fn append_log_bounded(
1007 ctx: &BuildCtx,
1008 build_id: db::BuildId,
1009 line: &str,
1010 ) -> crate::error::Result<()> {
1011 const TRUNCATED: &str = "[log truncated]\n";
1012 if let Some((current_len, already_truncated)) =
1013 db::builds::get_build_log_size(&ctx.db, build_id, TRUNCATED).await?
1014 && (current_len as usize) + line.len() > BUILD_MAX_LOG_BYTES
1015 {
1016 if !already_truncated {
1017 tracing::warn!(build_id = %build_id, "Build log exceeded {} bytes, truncating", BUILD_MAX_LOG_BYTES);
1018 db::builds::append_build_log(&ctx.db, build_id, TRUNCATED).await?;
1019 }
1020 return Ok(());
1021 }
1022 let sanitized = strip_ansi_escapes(line);
1023 db::builds::append_build_log(&ctx.db, build_id, &sanitized).await
1024 }
1025
1026 /// Strip ANSI escape sequences (e.g. color codes) from build output before
1027 /// storing it in the database.
1028 fn strip_ansi_escapes(s: &str) -> String {
1029 let mut result = String::with_capacity(s.len());
1030 let mut chars = s.chars();
1031 while let Some(c) = chars.next() {
1032 if c == '\x1b' {
1033 // Consume the next char; if it's '[' we have a CSI sequence
1034 // and we skip parameter/intermediate bytes up to the final byte.
1035 // Otherwise (OSC / other sequences) just drop the two-char escape.
1036 if let Some(next) = chars.next()
1037 && next == '['
1038 {
1039 // CSI sequence: skip until we hit a letter (0x40..=0x7E).
1040 for tail in chars.by_ref() {
1041 if tail.is_ascii_alphabetic() {
1042 break;
1043 }
1044 }
1045 }
1046 } else {
1047 result.push(c);
1048 }
1049 }
1050 result
1051 }
1052
1053 /// A build command parsed into a shell-injection-proof structured form.
1054 ///
1055 /// The operator-configured `build_command` is a single string (e.g.
1056 /// `RUSTFLAGS=--cfg cargo build --release`). Rather than interpolate it raw into
1057 /// the remote `sh -c` script, where its safety rested entirely on a
1058 /// metacharacter denylist, one added allowed character away from reopening
1059 /// injection, it is tokenised into leading `NAME=VALUE` environment
1060 /// assignments followed by a program and its arguments. `render` emits every
1061 /// element individually shell-escaped, applying assignments via `env`, so no
1062 /// operator byte can break out of its shell word. Shell injection is
1063 /// structurally impossible here, not denylist-gated; the per-token charset
1064 /// check below is defense-in-depth, no longer the sole guard.
1065 struct RemoteCommand {
1066 /// Leading `NAME=VALUE` assignments, applied via `env` before the program.
1067 assignments: Vec<String>,
1068 /// The program to execute.
1069 program: String,
1070 /// The program's arguments.
1071 args: Vec<String>,
1072 }
1073
1074 impl RemoteCommand {
1075 /// Parse a (template-substituted) build command string into its structured
1076 /// form. Tokenised on ASCII whitespace; leading `NAME=VALUE` tokens become
1077 /// env assignments, the first remaining token is the program, the rest are
1078 /// arguments. Each token is charset-validated as defense-in-depth.
1079 fn parse(cmd: &str) -> std::result::Result<Self, String> {
1080 if cmd.len() > 1024 {
1081 return Err("build command too long (max 1024 chars)".to_string());
1082 }
1083 let tokens: Vec<&str> = cmd.split_whitespace().collect();
1084 if tokens.is_empty() {
1085 return Err("build command is empty".to_string());
1086 }
1087 for tok in &tokens {
1088 validate_command_token(tok)?;
1089 }
1090
1091 let mut assignments = Vec::new();
1092 let mut rest = tokens.as_slice();
1093 while let Some((first, tail)) = rest.split_first() {
1094 if is_env_assignment(first) {
1095 assignments.push((*first).to_string());
1096 rest = tail;
1097 } else {
1098 break;
1099 }
1100 }
1101
1102 let (program, args) = rest.split_first().ok_or_else(|| {
1103 "build command has environment assignments but no program".to_string()
1104 })?;
1105
1106 Ok(Self {
1107 assignments,
1108 program: (*program).to_string(),
1109 args: args.iter().map(|s| (*s).to_string()).collect(),
1110 })
1111 }
1112
1113 /// Render as a single shell command line with every element escaped. Safe to
1114 /// interpolate into a larger `sh -c` script: no element can inject.
1115 fn render(&self) -> String {
1116 let mut parts = Vec::with_capacity(self.assignments.len() + self.args.len() + 2);
1117 if !self.assignments.is_empty() {
1118 parts.push("env".to_string());
1119 parts.extend(self.assignments.iter().map(|a| shell_escape(a)));
1120 }
1121 parts.push(shell_escape(&self.program));
1122 parts.extend(self.args.iter().map(|a| shell_escape(a)));
1123 parts.join(" ")
1124 }
1125 }
1126
1127 /// True if a token is a `NAME=VALUE` environment assignment (a valid shell
1128 /// identifier before the first `=`).
1129 fn is_env_assignment(tok: &str) -> bool {
1130 match tok.split_once('=') {
1131 Some((name, _)) => {
1132 !name.is_empty()
1133 && name.chars().enumerate().all(|(i, c)| {
1134 c == '_' || c.is_ascii_alphabetic() || (i > 0 && c.is_ascii_digit())
1135 })
1136 }
1137 None => false,
1138 }
1139 }
1140
1141 /// Validate a single build-command token's charset. Rejects shell
1142 /// metacharacters and control characters as defense-in-depth; the rendered
1143 /// command escapes every token regardless, so this is not the sole guard.
1144 fn validate_command_token(tok: &str) -> std::result::Result<(), String> {
1145 for (i, c) in tok.chars().enumerate() {
1146 match c {
1147 'a'..='z' | 'A'..='Z' | '0'..='9' => {}
1148 '-' | '_' | '.' | '/' | '=' | ':' | ',' | '+' | '{' | '}' | '@' => {}
1149 _ => {
1150 return Err(format!(
1151 "character '{}' at position {} in token '{}' is not allowed",
1152 c.escape_default(),
1153 i,
1154 tok.escape_default(),
1155 ));
1156 }
1157 }
1158 }
1159 Ok(())
1160 }
1161
1162 /// Validate a build command for shell safety at config-write time. Validation is
1163 /// exactly "parses into a [`RemoteCommand`]", the same parser the executor uses
1164 ///, so a stored command that validates here can never fail to render safely.
1165 pub fn validate_build_command(cmd: &str) -> std::result::Result<(), String> {
1166 RemoteCommand::parse(cmd).map(|_| ())
1167 }
1168
1169 /// Validate an artifact path for shell and path safety.
1170 ///
1171 /// Must be a relative path with no shell metacharacters or path traversal.
1172 pub fn validate_artifact_path(path: &str) -> std::result::Result<(), String> {
1173 if path.is_empty() {
1174 return Err("artifact path is empty".to_string());
1175 }
1176 if path.len() > 512 {
1177 return Err("artifact path too long (max 512 chars)".to_string());
1178 }
1179 if path.starts_with('/') {
1180 return Err("artifact path must be relative".to_string());
1181 }
1182 if path.contains("..") {
1183 return Err("artifact path must not contain '..'".to_string());
1184 }
1185 for (i, c) in path.chars().enumerate() {
1186 match c {
1187 'a'..='z' | 'A'..='Z' | '0'..='9' => {}
1188 '-' | '_' | '.' | '/' | '{' | '}' | '+' => {}
1189 ';' | '&' | '|' | '$' | '`' | '(' | ')' | '<' | '>' | '!' | '\\' | '\'' | '"' | ' '
1190 | '\n' | '\r' | '\0' => {
1191 return Err(format!(
1192 "character '{}' at position {} is not allowed in artifact path",
1193 c.escape_default(),
1194 i
1195 ));
1196 }
1197 _ => {
1198 return Err(format!(
1199 "unexpected character '{}' at position {} is not allowed in artifact path",
1200 c.escape_default(),
1201 i
1202 ));
1203 }
1204 }
1205 }
1206 Ok(())
1207 }
1208
1209 /// Escape a string for safe use in a shell command.
1210 fn shell_escape(s: &str) -> String {
1211 format!("'{}'", s.replace('\'', "'\\''"))
1212 }
1213
1214 #[cfg(test)]
1215 mod tests {
1216 use super::*;
1217
1218 #[test]
1219 fn the_update_hook_guards_the_namespace_validation_reserves() {
1220 // Bash cannot read a Rust constant, so the literal in the hook is a
1221 // copy. Renaming the reserved prefix without editing the hook would
1222 // leave the new one pushable and the old one locked, which is the
1223 // failure this pins: two doors, one policy.
1224 let reserved = crate::validation::RESERVED_NOTE_NAMESPACE;
1225 assert!(
1226 UPDATE_HOOK.contains(&format!("refs/notes/{reserved}|refs/notes/{reserved}/*")),
1227 "the update hook does not guard refs/notes/{reserved}/*:\n{UPDATE_HOOK}"
1228 );
1229 // The bare prefix and the subtree are separate patterns in a glob, and
1230 // matching only the subtree would leave `refs/notes/mnw` itself open.
1231 assert!(UPDATE_HOOK.contains("exit 1"), "{UPDATE_HOOK}");
1232 }
1233
1234 #[tokio::test]
1235 async fn read_capped_truncates_to_cap() {
1236 // 10k bytes through a 4k cap retains exactly 4k (the rest is drained and
1237 // discarded so the child never blocks on a full pipe).
1238 let data = vec![b'x'; 10_000];
1239 let out = read_capped(&data[..], 4096).await;
1240 assert_eq!(out.len(), 4096);
1241 }
1242
1243 #[tokio::test]
1244 async fn read_capped_returns_all_when_under_cap() {
1245 let out = read_capped(&b"hello world"[..], 4096).await;
1246 assert_eq!(out, "hello world");
1247 }
1248
1249 #[test]
1250 fn build_failure_message_partial() {
1251 assert_eq!(
1252 build_failure_message(1, 2, Some("boom")),
1253 "partial build failure (1/3 targets succeeded)"
1254 );
1255 assert_eq!(
1256 build_failure_message(2, 1, Some("boom")),
1257 "partial build failure (2/3 targets succeeded)"
1258 );
1259 }
1260
1261 #[test]
1262 fn build_failure_message_total_failure_uses_first_error() {
1263 assert_eq!(build_failure_message(0, 3, Some("ssh down")), "ssh down");
1264 assert_eq!(
1265 build_failure_message(0, 0, None),
1266 "no targets produced artifacts"
1267 );
1268 }
1269
1270 #[test]
1271 fn rust_target_mapping() {
1272 assert_eq!(
1273 rust_target("linux", "x86_64"),
1274 Some("x86_64-unknown-linux-gnu")
1275 );
1276 assert_eq!(
1277 rust_target("linux", "aarch64"),
1278 Some("aarch64-unknown-linux-gnu")
1279 );
1280 assert_eq!(rust_target("darwin", "x86_64"), Some("x86_64-apple-darwin"));
1281 assert_eq!(
1282 rust_target("darwin", "aarch64"),
1283 Some("aarch64-apple-darwin")
1284 );
1285 assert_eq!(rust_target("windows", "x86_64"), None);
1286 }
1287
1288 #[test]
1289 fn hook_template_contains_hmac_not_raw_token() {
1290 let hook = post_receive_hook("secret-token-123", "alice", "myrepo");
1291 let expected_hmac = repo_hmac("secret-token-123", "alice", "myrepo");
1292 assert!(
1293 hook.contains(&expected_hmac),
1294 "hook should contain per-repo HMAC"
1295 );
1296 assert!(
1297 !hook.contains("secret-token-123"),
1298 "hook must not contain raw token"
1299 );
1300 assert!(!hook.contains("__HMAC__"), "placeholder should be replaced");
1301 assert!(hook.contains("/api/internal/builds/trigger"));
1302 }
1303
1304 /// The two notes arms answer different refs and must not be confused for
1305 /// each other: an inbox push is merged and answered synchronously, a notes
1306 /// push is only indexed. A `case` pattern that caught both would either
1307 /// merge a ref that is already the namespace or leave a push unindexed.
1308 #[test]
1309 fn the_hook_indexes_a_notes_push_and_merges_an_inbox_push() {
1310 let hook = post_receive_hook("secret-token-123", "alice", "myrepo");
1311 assert!(hook.contains("/api/internal/notes/reindex"));
1312 assert!(hook.contains("/api/internal/notes/merge-inbox"));
1313 assert!(hook.contains("refs/notes/*)"));
1314 assert!(hook.contains("refs/mnw/notes-inbox/*)"));
1315 // The inbox lives under refs/mnw/, so nothing an inbox push does can
1316 // fall into the indexing arm. `notes_inbox` pins that prefix itself.
1317 assert!(!"refs/mnw/notes-inbox/commits".starts_with("refs/notes/"));
1318 }
1319
1320 /// Fixed vector, duplicated in mnw-cli's `repo_hmac` test. mnw-cli installs
1321 /// hooks for repos it auto-creates over SSH, and this endpoint verifies
1322 /// them; if either side's derivation moves, both tests have to move
1323 /// together or those pushes stop triggering builds.
1324 #[test]
1325 fn repo_hmac_matches_mnw_cli_vector() {
1326 assert_eq!(
1327 repo_hmac("test-token", "max", "repo"),
1328 "198b4a4f542c0c27c4ca333030dfe09fe670aa44bae34d7a586481bb13fd9eb0"
1329 );
1330 }
1331
1332 #[test]
1333 fn repo_hmac_differs_per_repo() {
1334 let h1 = repo_hmac("token", "alice", "repo-a");
1335 let h2 = repo_hmac("token", "alice", "repo-b");
1336 assert_ne!(h1, h2, "different repos should produce different HMACs");
1337 }
1338
1339 #[test]
1340 fn shell_escape_basic() {
1341 assert_eq!(shell_escape("hello"), "'hello'");
1342 assert_eq!(shell_escape("it's"), "'it'\\''s'");
1343 }
1344
1345 #[test]
1346 fn validate_build_command_accepts_safe_commands() {
1347 assert!(
1348 validate_build_command("cargo build --release --target x86_64-unknown-linux-gnu")
1349 .is_ok()
1350 );
1351 assert!(validate_build_command("make -j4").is_ok());
1352 assert!(validate_build_command("RUSTFLAGS=--cfg tokio_unstable cargo build").is_ok());
1353 }
1354
1355 #[test]
1356 fn validate_build_command_rejects_injection() {
1357 assert!(validate_build_command("cargo build; curl evil.com").is_err());
1358 assert!(validate_build_command("cargo build && rm -rf /").is_err());
1359 assert!(validate_build_command("cargo build | tee log").is_err());
1360 assert!(validate_build_command("$(whoami)").is_err());
1361 assert!(validate_build_command("`whoami`").is_err());
1362 assert!(validate_build_command("cargo build > /dev/null").is_err());
1363 assert!(validate_build_command("").is_err());
1364 assert!(
1365 validate_build_command(" ").is_err(),
1366 "whitespace-only has no program"
1367 );
1368 assert!(
1369 validate_build_command("FOO=bar").is_err(),
1370 "assignment with no program"
1371 );
1372 }
1373
1374 #[test]
1375 fn remote_command_parse_separates_env_program_args() {
1376 let c = RemoteCommand::parse("cargo build --release").unwrap();
1377 assert!(c.assignments.is_empty());
1378 assert_eq!(c.program, "cargo");
1379 assert_eq!(c.args, vec!["build", "--release"]);
1380
1381 let c = RemoteCommand::parse("RUSTFLAGS=--cfg CARGO_INCREMENTAL=0 cargo build").unwrap();
1382 assert_eq!(
1383 c.assignments,
1384 vec!["RUSTFLAGS=--cfg", "CARGO_INCREMENTAL=0"]
1385 );
1386 assert_eq!(c.program, "cargo");
1387 assert_eq!(c.args, vec!["build"]);
1388 }
1389
1390 #[test]
1391 fn remote_command_render_escapes_every_token() {
1392 // Plain command: each token individually single-quoted.
1393 let c = RemoteCommand::parse("cargo build --release").unwrap();
1394 assert_eq!(c.render(), "'cargo' 'build' '--release'");
1395
1396 // Env prefix: applied via `env`, each element escaped.
1397 let c = RemoteCommand::parse("RUSTFLAGS=--cfg cargo build").unwrap();
1398 assert_eq!(c.render(), "env 'RUSTFLAGS=--cfg' 'cargo' 'build'");
1399 }
1400
1401 #[test]
1402 fn is_env_assignment_recognizes_valid_identifiers_only() {
1403 assert!(is_env_assignment("FOO=bar"));
1404 assert!(is_env_assignment("_X1=y"));
1405 assert!(is_env_assignment("A=")); // empty value is a valid assignment
1406 assert!(
1407 !is_env_assignment("1FOO=bar"),
1408 "identifier can't start with a digit"
1409 );
1410 assert!(!is_env_assignment("cargo"), "no '='");
1411 assert!(!is_env_assignment("--target=x"), "not a shell identifier");
1412 }
1413
1414 #[test]
1415 fn render_defuses_would_be_injection_even_if_charset_bypassed() {
1416 // Construct a RemoteCommand directly with a hostile arg (bypassing the
1417 // token charset check) to prove render() is the real guard: the shell
1418 // sees a single quoted word, not a command separator.
1419 let c = RemoteCommand {
1420 assignments: vec![],
1421 program: "cargo".to_string(),
1422 args: vec!["build; rm -rf /".to_string()],
1423 };
1424 assert_eq!(c.render(), "'cargo' 'build; rm -rf /'");
1425 }
1426
1427 #[test]
1428 fn validate_artifact_path_accepts_safe_paths() {
1429 assert!(validate_artifact_path("target/x86_64-unknown-linux-gnu/release/myapp").is_ok());
1430 assert!(validate_artifact_path("dist/app-v0.1.0.tar.gz").is_ok());
1431 }
1432
1433 #[test]
1434 fn validate_artifact_path_rejects_unsafe() {
1435 assert!(validate_artifact_path("/etc/passwd").is_err());
1436 assert!(validate_artifact_path("../../../etc/passwd").is_err());
1437 assert!(validate_artifact_path("path with spaces").is_err());
1438 assert!(validate_artifact_path("$(whoami)").is_err());
1439 assert!(validate_artifact_path("").is_err());
1440 }
1441 }
1442