freenet 0.2.87

Freenet core software
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
// Every item in this module is `#[cfg(target_os = "linux")]`, so gate the
// imports too — otherwise they are unused on macOS/Windows builds.
#[cfg(target_os = "linux")]
use anyhow::{Context, Result};
#[cfg(target_os = "linux")]
use std::path::Path;

/// Path to the system-wide systemd service file.
#[cfg(target_os = "linux")]
const SYSTEM_SERVICE_PATH: &str = "/etc/systemd/system/freenet.service";

/// Check if a system-wide Freenet service is installed.
#[cfg(target_os = "linux")]
fn has_system_service() -> bool {
    Path::new(SYSTEM_SERVICE_PATH).exists()
}

/// Check if a user-level Freenet service is installed.
#[cfg(target_os = "linux")]
fn has_user_service() -> bool {
    dirs::home_dir()
        .map(|h| h.join(".config/systemd/user/freenet.service").exists())
        .unwrap_or(false)
}

/// Recursively chown a directory to the given user (best-effort).
/// Used after creating directories with sudo so the service user can write to them.
#[cfg(target_os = "linux")]
fn chown_to_user(path: &Path, username: &str) {
    let _status = std::process::Command::new("chown")
        .args(["-R", username, &path.display().to_string()])
        .status();
}

/// Look up a user's home directory from /etc/passwd via `getent passwd`.
/// Falls back to `/home/{username}` if getent is unavailable.
#[cfg(target_os = "linux")]
pub(super) fn home_dir_for_user(username: &str) -> std::path::PathBuf {
    // Try getent passwd which works with NSS (LDAP, NIS, etc.)
    if let Ok(output) = std::process::Command::new("getent")
        .args(["passwd", username])
        .output()
    {
        if output.status.success() {
            let line = String::from_utf8_lossy(&output.stdout);
            // Format: username:x:uid:gid:gecos:home:shell
            if let Some(home) = line.split(':').nth(5) {
                let home = home.trim();
                if !home.is_empty() {
                    return std::path::PathBuf::from(home);
                }
            }
        }
    }
    std::path::PathBuf::from(format!("/home/{username}"))
}

/// Resolve whether to use system or user mode.
/// If `--system` is passed, use system mode. Otherwise auto-detect based on
/// which service file exists, defaulting to user mode.
#[cfg(target_os = "linux")]
fn use_system_mode(system_flag: bool) -> bool {
    // Auto-detect: if only system service exists, use system mode
    system_flag || (has_system_service() && !has_user_service())
}

/// Run a systemctl command, using --user or not based on system mode.
#[cfg(target_os = "linux")]
fn systemctl(system_mode: bool, args: &[&str]) -> Result<std::process::ExitStatus> {
    let mut cmd = std::process::Command::new("systemctl");
    if !system_mode {
        cmd.arg("--user");
    }
    cmd.args(args);
    let status = cmd.status().context("Failed to run systemctl")?;
    Ok(status)
}

/// Run a systemctl command with helpful error on user-session failures.
#[cfg(target_os = "linux")]
fn systemctl_with_hint(system_mode: bool, args: &[&str], action: &str) -> Result<()> {
    let status = systemctl(system_mode, args)?;
    if status.success() {
        return Ok(());
    }

    if system_mode {
        anyhow::bail!("Failed to {action}");
    }

    // Check if this looks like a user session bus issue
    let hint = std::process::Command::new("systemctl")
        .args(["--user", "daemon-reload"])
        .stderr(std::process::Stdio::piped())
        .output()
        .ok()
        .and_then(|out| {
            let stderr = String::from_utf8_lossy(&out.stderr);
            if stderr.contains("bus")
                || stderr.contains("XDG_RUNTIME_DIR")
                || stderr.contains("Failed to connect")
            {
                Some(
                    "\n\nHint: User systemd session not available (common in containers/LXC).\n\
                     Try: sudo freenet service install --system",
                )
            } else {
                None
            }
        })
        .unwrap_or("");

    anyhow::bail!("Failed to {action}{hint}");
}

#[cfg(target_os = "linux")]
pub(super) fn install_service(system: bool, no_linger: bool) -> Result<()> {
    if system {
        // A system service runs at boot independent of any login session, so
        // lingering does not apply to it; `no_linger` is ignored here.
        install_system_service()
    } else {
        install_user_service(no_linger)
    }
}

/// The lingering action implied by the install flags. Pure decision split out
/// from the side-effecting install so the policy can be unit-tested:
/// a system service never lingers (it starts at boot); a user service enables
/// lingering by default and skips it only on `--no-linger`.
#[cfg(target_os = "linux")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LingerAction {
    /// System service: lingering is irrelevant (runs at boot regardless).
    SystemService,
    /// User service: enable lingering so it survives logout (the default).
    EnableForUser,
    /// User service with `--no-linger`: keep the service login-scoped.
    SkipForUser,
}

#[cfg(target_os = "linux")]
fn linger_action(system: bool, no_linger: bool) -> LingerAction {
    if system {
        LingerAction::SystemService
    } else if no_linger {
        LingerAction::SkipForUser
    } else {
        LingerAction::EnableForUser
    }
}

/// Resolve the current login name, for enabling systemd lingering on a user
/// service. Prefers `$USER`/`$LOGNAME`, falling back to `id -un`.
#[cfg(target_os = "linux")]
fn current_username() -> Option<String> {
    for var in ["USER", "LOGNAME"] {
        if let Ok(value) = std::env::var(var) {
            let value = value.trim();
            if !value.is_empty() {
                return Some(value.to_string());
            }
        }
    }
    std::process::Command::new("id")
        .arg("-un")
        .output()
        .ok()
        .filter(|out| out.status.success())
        .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
        .filter(|name| !name.is_empty())
}

/// Whether systemd lingering is already enabled for `username`.
#[cfg(target_os = "linux")]
fn linger_enabled(username: &str) -> bool {
    std::process::Command::new("loginctl")
        .args(["show-user", username, "--property=Linger", "--value"])
        .output()
        .ok()
        .filter(|out| out.status.success())
        .map(|out| String::from_utf8_lossy(&out.stdout).trim() == "yes")
        .unwrap_or(false)
}

/// Enable systemd "lingering" for `username` so their `--user` services keep
/// running without an active login session.
///
/// This is ESSENTIAL for a headless server: without lingering, a `--user`
/// service is bound to the user's login session and is stopped at logout, so
/// it never restarts on boot and never catches the node's exit-42 "update
/// needed" signal — the node silently stops auto-updating. Enabling lingering
/// is the user-service counterpart to a `--system` service starting at boot.
///
/// Best-effort: a failure only weakens auto-update across logout, so we warn
/// (with a remediation hint) rather than aborting the install. Idempotent: a
/// no-op when lingering is already enabled.
#[cfg(target_os = "linux")]
fn enable_linger(username: &str) {
    if linger_enabled(username) {
        println!("systemd lingering already enabled for user '{username}'.");
        return;
    }
    match std::process::Command::new("loginctl")
        .args(["enable-linger", username])
        .status()
    {
        Ok(status) if status.success() => {
            println!(
                "Enabled systemd lingering for user '{username}' \
                 (the service runs without an active login session, so it \
                 auto-updates on a headless server)."
            );
        }
        _ => {
            eprintln!(
                "Warning: could not enable systemd lingering for user '{username}'.\n\
                 The user service will run ONLY while you are logged in, so it will\n\
                 NOT auto-update on a headless server. Enable it manually with:\n  \
                 sudo loginctl enable-linger {username}\n\
                 Or reinstall as a system service:\n  \
                 sudo freenet service install --system"
            );
        }
    }
}

#[cfg(target_os = "linux")]
fn install_user_service(no_linger: bool) -> Result<()> {
    use std::fs;

    let exe_path = std::env::current_exe().context("Failed to get current executable path")?;
    let home_dir = dirs::home_dir().context("Failed to get home directory")?;

    let service_dir = home_dir.join(".config/systemd/user");
    fs::create_dir_all(&service_dir).context("Failed to create systemd user directory")?;

    // Create log directory - use ~/.local/state/freenet for XDG compliance
    let log_dir = home_dir.join(".local/state/freenet");
    fs::create_dir_all(&log_dir).context("Failed to create log directory")?;

    let service_content = generate_user_service_file(&exe_path, &log_dir);
    let service_path = service_dir.join("freenet.service");

    fs::write(&service_path, &service_content).context("Failed to write service file")?;

    // Sidecar records the unit's SHA-256 so a later `freenet update` can
    // distinguish "Freenet's unit" from a hand-edited one before
    // overwriting (#4287). A failed sidecar write only weakens future
    // user-modification protection — warn and continue.
    let hash_path = service_path.with_extension("service.hash");
    let unit_hash = super::super::update::wrapper_content_hash(&service_content);
    if let Err(e) = super::super::update::write_wrapper_hash_sidecar(&hash_path, &unit_hash) {
        eprintln!(
            "Warning: failed to write service hash sidecar at {}: {}.",
            hash_path.display(),
            e
        );
    }

    // Reload systemd user daemon
    systemctl_with_hint(false, &["daemon-reload"], "reload systemd daemon")?;

    // Enable the service
    systemctl_with_hint(false, &["enable", "freenet"], "enable service")?;

    // Enable lingering by default so the service runs without an active login
    // session (the headless-server auto-update requirement). Opt out with
    // `--no-linger` to keep the service login-scoped.
    //
    // install_user_service is only reached for a user install, so the
    // SystemService arm is unreachable here, but matching exhaustively keeps
    // the policy in one place.
    let action = linger_action(false, no_linger);
    let username = current_username();
    let lingering = match action {
        LingerAction::EnableForUser => match &username {
            Some(user) => {
                enable_linger(user);
                linger_enabled(user)
            }
            None => {
                eprintln!(
                    "Warning: could not determine the current username to enable \
                     systemd lingering. The user service will run only while you are \
                     logged in (no auto-update across logout). Enable it manually with:\n  \
                     sudo loginctl enable-linger <your-user>"
                );
                false
            }
        },
        LingerAction::SkipForUser | LingerAction::SystemService => false,
    };

    let user_label = username.as_deref().unwrap_or("<your-user>");

    println!("Freenet user service installed successfully.");
    println!();
    println!("To start the service now:");
    println!("  freenet service start");
    println!();
    if lingering {
        println!("The service will start automatically on login and on boot");
        println!("(systemd lingering is enabled, so it keeps running when you log out).");
    } else if matches!(action, LingerAction::SkipForUser) {
        // The operator explicitly opted out, so don't nag with a remediation;
        // just state the consequence plainly.
        println!("The service will start automatically on login.");
        println!("Lingering is disabled (--no-linger): the service stops at logout, so it");
        println!("will not auto-update on a headless server. Re-enable it any time with:");
        println!("  sudo loginctl enable-linger {user_label}");
    } else {
        println!("The service will start automatically on login.");
        println!("NOTE: lingering could NOT be enabled, so the service stops at logout and");
        println!("      will not auto-update on a headless server. Enable it with:");
        println!("        sudo loginctl enable-linger {user_label}");
    }
    println!("Logs will be written to: {}", log_dir.display());

    Ok(())
}

#[cfg(target_os = "linux")]
fn install_system_service() -> Result<()> {
    use std::fs;

    let exe_path = std::env::current_exe().context("Failed to get current executable path")?;

    // Get the user to run the service as.
    // When running with sudo, SUDO_USER has the original (non-root) user.
    let username = std::env::var("SUDO_USER")
        .or_else(|_| std::env::var("USER"))
        .or_else(|_| std::env::var("LOGNAME"))
        .context(
            "Could not determine username. Set the USER environment variable \
             or run with sudo (which sets SUDO_USER).",
        )?;

    if username == "root" {
        anyhow::bail!(
            "Refusing to install system service running as root.\n\
             Run with sudo from a non-root user account so SUDO_USER is set,\n\
             or set the USER environment variable to the desired service user."
        );
    }

    // Look up the user's home directory from /etc/passwd.
    // When running with sudo, dirs::home_dir() returns /root which is wrong.
    let home_dir = home_dir_for_user(&username);

    // Create log directory and fix ownership (we're running as root via sudo,
    // but the service will run as the target user).
    let log_dir = home_dir.join(".local/state/freenet");
    fs::create_dir_all(&log_dir).context("Failed to create log directory")?;
    chown_to_user(&log_dir, &username);

    let service_content = generate_system_service_file(&exe_path, &log_dir, &username, &home_dir);

    fs::write(SYSTEM_SERVICE_PATH, &service_content).with_context(|| {
        format!(
            "Failed to write service file to {SYSTEM_SERVICE_PATH}. \
             Are you running as root? Try: sudo freenet service install --system"
        )
    })?;

    // Sidecar records the unit's SHA-256 so a later `freenet update` can
    // distinguish "Freenet's unit" from a hand-edited one before
    // overwriting (#4287). Lives next to the root-owned unit and is
    // written by the same root process, so root owns it too. A failed
    // sidecar write only weakens future user-modification protection —
    // warn and continue.
    let system_hash_path = Path::new(SYSTEM_SERVICE_PATH).with_extension("service.hash");
    let unit_hash = super::super::update::wrapper_content_hash(&service_content);
    if let Err(e) = super::super::update::write_wrapper_hash_sidecar(&system_hash_path, &unit_hash)
    {
        eprintln!(
            "Warning: failed to write service hash sidecar at {}: {}.",
            system_hash_path.display(),
            e
        );
    }

    // Reload systemd daemon (system-level, no --user)
    let status = systemctl(true, &["daemon-reload"])?;
    if !status.success() {
        anyhow::bail!("Failed to reload systemd daemon");
    }

    // Enable the service
    let status = systemctl(true, &["enable", "freenet"])?;
    if !status.success() {
        anyhow::bail!("Failed to enable service");
    }

    println!("Freenet system service installed successfully.");
    println!("  Service runs as user: {username}");
    println!();
    println!("To start the service now:");
    println!("  sudo freenet service start --system");
    println!();
    println!("The service will start automatically on boot.");
    println!("Logs will be written to: {}", log_dir.display());

    Ok(())
}

#[cfg(target_os = "linux")]
pub fn generate_user_service_file(binary_path: &Path, log_dir: &Path) -> String {
    format!(
        r#"[Unit]
Description=Freenet Node
Documentation=https://freenet.org
After=network-online.target
Wants=network-online.target
# Crash-loop rate limiting (issue #4551). StartLimitBurst / StartLimitIntervalSec
# are [Unit]-section directives (#4570 moved them here from [Service], where systemd
# SILENTLY IGNORES them — which had disabled the limiter entirely, so with
# Restart=always + RestartSec=10 a crashing node restarted every ~10s forever).
#
# Only a RAPID loop trips this: more than 5 starts within 120s. A legitimate
# single restart (auto-update, an occasional reboot, a one-off crash) is nowhere
# near 5-in-120s and is never penalised. A boot-wedge / fast crash exits 45 (a
# counted failure — see SuccessExitStatus in [Service]), so a tight no-fix loop of
# those trips the limiter and the unit stops; a node that ran healthily first
# exits the burst-exempt code 42 (see crate::node::p2p_impl). BOTH 42 and 45 still
# fire the ExecStopPost `freenet update` self-heal in [Service], so a boot-crash
# that a newer release fixes recovers even though exit 45 is counted.
#
# StartLimitAction=none (the default, made explicit) STOPS the unit on trip (it
# enters "failed") rather than rebooting the host — the desired terminal state
# for a crash loop: better than looping forever, and loud to the operator.
# Recover with `systemctl reset-failed freenet && systemctl start freenet` (the
# lockout also clears on the next reboot / `freenet update`).
StartLimitBurst=5
StartLimitIntervalSec=120
StartLimitAction=none

[Service]
Type=simple
# Mark the node as supervised (issue #4580): tells `freenet network` that exit
# code 42 (update needed) will be applied by ExecStopPost below, so it logs an
# informational message instead of a loud "no supervisor" error on update exit.
# systemd also sets INVOCATION_ID for every service, which the node detects as a
# fallback; this makes the intent explicit and robust.
Environment=FREENET_SUPERVISED=1
# #4551: advertise to the node that THIS unit understands the fast-crash exit code
# 45 — it keeps 45 out of SuccessExitStatus (so 45 counts toward StartLimitBurst),
# sets StartLimitAction=none, and runs `freenet update` on exit 42 OR 45. The node
# emits 45 only when it sees this marker, so a binary running under an OLD unit
# (auto-updated but not reinstalled) or the mac/Windows wrapper keeps emitting the
# self-healing exit 42 instead of a 45 the supervisor would mishandle.
Environment={fast_crash_marker}=1
# Stale-orphan self-heal (issue #3967): RestartPreventExitStatus=43 below
# means an exit 43 ("another instance already running") never restarts the
# unit. That is correct for a legitimate second instance, but if the port
# holder is an ORPHANED `freenet network` (PPID=1) still running an OLD
# binary, the unit would stand down and the orphan would serve stale assets
# forever. This pre-flight runs before every start: it finds the port
# holder, and kills it ONLY when it is an init-adopted orphan (PPID==1) whose
# `Freenet version:` line differs from the binary this unit would launch (or
# whose version can't be read). A user-run `freenet network` (parented by a
# shell, PPID!=1) is always left alone, as is a current-version orphan.
#
# systemd performs its own $VAR/${{VAR}} expansion on Exec* lines BEFORE handing
# the string to /bin/sh, so every dollar the SHELL must see is written as $$
# here (systemd collapses $$ -> a single $ for sh). Self-match guards: the
# pre-flight sh's OWN argv contains the literal "freenet network" (it is the
# substring `pgrep -f` matches), so the pre-flight excludes its own PID ($$$$ ->
# the sh's $$) and PID 1 from the holder loop. We deliberately do NOT anchor on
# the holder's exe equalling THIS unit's on-disk binary: a #3967 orphan is, by
# definition, running an OLD/DIFFERENT binary, so an `exe == on-disk binary`
# guard would skip exactly the orphan we must kill. The PPID==1 + version-line
# checks below are what distinguish a stale orphan from a legitimate holder.
# PPID is read after the final ')' in /proc/PID/stat (comm is parenthesized) so
# a comm containing whitespace can't shift the field. The '-' prefix means a
# failure here never blocks the start.
ExecStartPre=-/bin/sh -c 'self=$$$$; ondisk=$$(timeout 5 {binary} --version 2>/dev/null | grep "^Freenet version:"); for pid in $$(pgrep -f -u "$$(id -u)" "freenet network" 2>/dev/null); do [ "$$pid" = "$$self" ] && continue; [ "$$pid" = "1" ] && continue; exe=$$(readlink -f /proc/$$pid/exe 2>/dev/null); hv=""; [ -x "$$exe" ] && hv=$$(timeout 5 "$$exe" --version 2>/dev/null | grep "^Freenet version:"); ppid=$$(sed "s/.*) //" /proc/$$pid/stat 2>/dev/null | awk "{{print \$$2}}"); mismatch=1; [ -n "$$ondisk" ] && [ -n "$$hv" ] && [ "$$hv" != "$$ondisk" ] && mismatch=0; if [ "$$ppid" = "1" ] && {{ [ "$$mismatch" = "0" ] || [ -z "$$hv" ]; }}; then kill -TERM "$$pid" 2>/dev/null || true; w=0; while kill -0 "$$pid" 2>/dev/null && [ $$w -lt 12 ]; do sleep 1; w=$$((w+1)); done; kill -0 "$$pid" 2>/dev/null && kill -KILL "$$pid" 2>/dev/null || true; fi; done'
ExecStart={binary} network
Restart=always
# Wait 10 seconds before restart to avoid rapid restart loops. The actual
# crash-loop cap (StartLimit*) lives in the [Unit] section above (#4551).
RestartSec=10
# Restart backoff (#4073): grow the inter-restart delay from RestartSec up to
# RestartMaxDelaySec over RestartSteps restarts, so a residual crash/exit loop
# slows down (=> up to 300s between restarts) instead of reconnecting to the
# gateway every ~10s -- gentler on the gateways, and it keeps the node trying for
# a fix instead of hammering.
#
# DELIBERATE tradeoff with the #4551 crash-loop limiter (StartLimitBurst=5 /
# StartLimitIntervalSec=120, StartLimitAction=none in [Unit]). The growing delay
# changes WHICH crashes reach #4551's terminal "failed-state stop":
#   * A TRUE fast crash (sub-second startup wedge) still clusters its first ~5
#     restarts well inside the 120s window while the delay is still small, so it
#     STILL trips StartLimitBurst and the unit terminal-stops. The #4551
#     brick-loop guarantee is preserved for the case it was designed for.
#   * A MEDIUM-runtime crash (node dies tens of seconds in) can, once the delay
#     has grown toward 300s, be spaced PAST the 120s burst window, so it no
#     longer trips StartLimitBurst and instead slow-flaps at <=300s rather than
#     terminal-stopping. This is accepted: such a node is not a tight brick-loop,
#     the slow cadence is easy on the gateways, and continuing to restart lets a
#     later fixed release (via the ExecStopPost `freenet update` below) heal it.
#     A bad UPDATE that crash-loops is still disarmed within its probation window
#     by the #4073/#4591 auto-rollback regardless of this cadence.
#
# These directives require systemd >= 254; OLDER systemd silently IGNORES unknown
# directives (they are not parse errors), so the unit degrades gracefully to the
# fixed RestartSec=10 above (and #4551's limiter then behaves exactly as before).
RestartSteps=10
RestartMaxDelaySec=300
# Allow 45 seconds for graceful shutdown before SIGKILL.
# The node handles SIGTERM by (1) waiting up to `shutdown-drain-secs`
# (default 30s) for in-flight client PUT/GET/UPDATE/SUBSCRIBE drivers
# to finish, then (2) closing peer connections. The 15s headroom over
# the default drain covers peer-connection teardown + spawn-task
# cleanup. If you raise `shutdown-drain-secs`, raise this in lockstep.
TimeoutStopSec=45

# Auto-update / crash-loop rollback / boot-crash self-heal: run `freenet update`
# before systemd restarts the service on ANY non-graceful exit — every
# $EXIT_STATUS except 0 (graceful) and 43 (another instance already running).
# That covers the voluntary update-needed exit 42, the fast-crash code 45
# (#4551), AND the crash codes the listener watchdog never produces: panics
# (101), SIGABRT/SIGKILL/SIGSEGV (134/137/139, which systemd may pass as signal
# NAMES like ABRT/KILL/SEGV), and early-startup errors (1). `freenet update`
# then either steps forward (42 / a newer release) or, for a crash of a
# freshly-installed version still on probation, counts it and — once it keeps
# crashing — rolls back to the previous binary (#4073). `freenet update` is a
# SEPARATE process that can succeed even when `freenet network` crashes at
# startup, preserving the #4549 self-heal.
# This does NOT exempt crash codes from the limiter: ExecStopPost runs AFTER the
# process exits (the '-' prefix means its own result never affects restart) and
# 45 / 1 / 101 / signals stay OUT of SuccessExitStatus below, so a no-fix tight
# loop still trips StartLimitBurst and the unit stops. A `case` (not `&&`/`||`)
# avoids shell-precedence pitfalls. $$EXIT_STATUS is doubled so systemd passes a
# literal $EXIT_STATUS through to sh. {post_stop_env} forwards that status to
# `freenet update` so #4073 can tell a post-stop restart from a manual update
# and classify the crash; an OLD binary (e.g. one we rolled back TO) ignores the
# unknown env var.
ExecStopPost=-/bin/sh -c 'case "$$EXIT_STATUS" in 0|43) ;; *) {post_stop_env}="$$EXIT_STATUS" {binary} update --quiet ;; esac'
# Exit 42 (auto-update) and 43 (another instance) are clean exits, so they are
# not counted as failures — without this, rapid update cycles (exit 42 →
# ExecStopPost → restart) could exhaust the burst limit and kill the service.
# Exit 45 (fast-crash / boot-wedge, #4551) is deliberately NOT whitelisted here:
# it must COUNT toward StartLimitBurst so a tight crash loop trips the [Unit]
# limiter and the unit stops — even though the ExecStopPost above still runs
# `freenet update` for it. Counting (StartLimit) and self-heal (ExecStopPost) are
# independent: the former bounds a no-fix loop, the latter rescues a boot-crash a
# newer release fixes. (44 is the internal bundle-update-staged code, never
# emitted by `freenet network`.)
SuccessExitStatus=42 43
# Exit code 43 = another instance is already running on the port.
# Do NOT restart — the existing instance is healthy.
RestartPreventExitStatus=43

# Logging
# - The node's tracing layer writes its own size-capped, hourly-rotated
#   logs to {log_dir}/freenet.YYYY-MM-DD-HH.log (LOG_RETENTION_HOURS +
#   LOG_DIR_MAX_BYTES; see crates/core/src/tracing.rs).
# - systemd's StandardOutput/StandardError previously appended to a fixed
#   freenet.log / freenet.error.log that the time-based cleanup never
#   pruned (mtime stayed fresh while the file was being written), so they
#   grew without bound on long-running nodes (issue #4251).
# - Routing both to the journal lets journald handle rotation, and panics
#   or pre-tracing-init output remain queryable via
#   `journalctl --user-unit freenet`.
StandardOutput=journal
StandardError=journal
SyslogIdentifier=freenet

# Resource limits to prevent runaway resource consumption
# File descriptors needed for network connections
LimitNOFILE=65536
# Memory limit (2GB soft limit for user service)
MemoryMax=2G
# CPU quota (200% = 2 cores max)
CPUQuota=200%

[Install]
WantedBy=default.target
"#,
        binary = binary_path.display(),
        log_dir = log_dir.display(),
        fast_crash_marker = super::super::auto_update::SYSTEMD_FAST_CRASH_ENV_VAR,
        post_stop_env = super::super::rollback::POST_STOP_EXIT_CODE_ENV_VAR,
    )
}

#[cfg(target_os = "linux")]
pub fn generate_system_service_file(
    binary_path: &Path,
    log_dir: &Path,
    username: &str,
    home_dir: &Path,
) -> String {
    format!(
        r#"[Unit]
Description=Freenet Node
Documentation=https://freenet.org
After=network-online.target
Wants=network-online.target
# Crash-loop rate limiting (issue #4551) — see the matching comment in the user
# unit. StartLimit* live in [Unit] (#4570); systemd silently ignores them in
# [Service]. Only a rapid loop (>5 starts / 120s) trips; a fast crash exits the
# counted code 45 while a healthy-then-died node exits the burst-exempt 42.
# StartLimitAction=none stops the unit (it does NOT reboot the host) on trip.
StartLimitBurst=5
StartLimitIntervalSec=120
StartLimitAction=none

[Service]
Type=simple
User={username}
Environment=HOME={home}
# Mark the node as supervised (issue #4580): see the matching comment in the
# user unit. Tells `freenet network` that exit code 42 (update needed) will be
# applied by ExecStopPost below, so it logs an informational message instead of
# a loud "no supervisor" error on update exit.
Environment=FREENET_SUPERVISED=1
# #4551 fast-crash-aware marker — see the matching comment in the user unit. The
# node emits exit 45 only when this is present (this unit handles 45); otherwise it
# keeps the self-healing exit 42.
Environment={fast_crash_marker}=1
# Stale-orphan self-heal (issue #3967): see the matching comment in the user
# unit (including the systemd $$-escaping, the PPID-after-final-')' parse, and
# why we do NOT anchor on the holder's exe equalling this unit's on-disk binary
# — a #3967 orphan runs an OLD/DIFFERENT binary, so that anchor would skip the
# very process we must kill). The self-PID and PID-1 skips exclude the
# pre-flight's own sh (whose argv contains the literal "freenet network").
# RestartPreventExitStatus=43 means an exit 43 never restarts the unit, so an
# init-adopted orphan (PPID==1) running an OLD binary would hold the port
# forever. This pre-flight kills the holder ONLY when it is such an orphan whose
# `Freenet version:` differs from (or can't be read against) the binary this
# unit launches; a user-run instance (PPID!=1) is always left alone. The '-'
# prefix means a failure here never blocks the start.
ExecStartPre=-/bin/sh -c 'self=$$$$; ondisk=$$(timeout 5 {binary} --version 2>/dev/null | grep "^Freenet version:"); for pid in $$(pgrep -f -u "$$(id -u)" "freenet network" 2>/dev/null); do [ "$$pid" = "$$self" ] && continue; [ "$$pid" = "1" ] && continue; exe=$$(readlink -f /proc/$$pid/exe 2>/dev/null); hv=""; [ -x "$$exe" ] && hv=$$(timeout 5 "$$exe" --version 2>/dev/null | grep "^Freenet version:"); ppid=$$(sed "s/.*) //" /proc/$$pid/stat 2>/dev/null | awk "{{print \$$2}}"); mismatch=1; [ -n "$$ondisk" ] && [ -n "$$hv" ] && [ "$$hv" != "$$ondisk" ] && mismatch=0; if [ "$$ppid" = "1" ] && {{ [ "$$mismatch" = "0" ] || [ -z "$$hv" ]; }}; then kill -TERM "$$pid" 2>/dev/null || true; w=0; while kill -0 "$$pid" 2>/dev/null && [ $$w -lt 12 ]; do sleep 1; w=$$((w+1)); done; kill -0 "$$pid" 2>/dev/null && kill -KILL "$$pid" 2>/dev/null || true; fi; done'
ExecStart={binary} network
Restart=always
# Wait 10 seconds before restart to avoid rapid restart loops. The actual
# crash-loop cap (StartLimit*) lives in the [Unit] section above (#4551).
RestartSec=10
# Restart backoff (#4073): grow the inter-restart delay from RestartSec up to
# RestartMaxDelaySec over RestartSteps restarts, so a residual crash/exit loop
# slows down (=> up to 300s between restarts) instead of reconnecting to the
# gateway every ~10s -- gentler on the gateways, and it keeps the node trying for
# a fix instead of hammering.
#
# DELIBERATE tradeoff with the #4551 crash-loop limiter (StartLimitBurst=5 /
# StartLimitIntervalSec=120, StartLimitAction=none in [Unit]). The growing delay
# changes WHICH crashes reach #4551's terminal "failed-state stop":
#   * A TRUE fast crash (sub-second startup wedge) still clusters its first ~5
#     restarts well inside the 120s window while the delay is still small, so it
#     STILL trips StartLimitBurst and the unit terminal-stops. The #4551
#     brick-loop guarantee is preserved for the case it was designed for.
#   * A MEDIUM-runtime crash (node dies tens of seconds in) can, once the delay
#     has grown toward 300s, be spaced PAST the 120s burst window, so it no
#     longer trips StartLimitBurst and instead slow-flaps at <=300s rather than
#     terminal-stopping. This is accepted: such a node is not a tight brick-loop,
#     the slow cadence is easy on the gateways, and continuing to restart lets a
#     later fixed release (via the ExecStopPost `freenet update` below) heal it.
#     A bad UPDATE that crash-loops is still disarmed within its probation window
#     by the #4073/#4591 auto-rollback regardless of this cadence.
#
# These directives require systemd >= 254; OLDER systemd silently IGNORES unknown
# directives (they are not parse errors), so the unit degrades gracefully to the
# fixed RestartSec=10 above (and #4551's limiter then behaves exactly as before).
RestartSteps=10
RestartMaxDelaySec=300
# Allow 45 seconds for graceful shutdown before SIGKILL.
# The node handles SIGTERM by (1) waiting up to `shutdown-drain-secs`
# (default 30s) for in-flight client PUT/GET/UPDATE/SUBSCRIBE drivers
# to finish, then (2) closing peer connections. The 15s headroom over
# the default drain covers peer-connection teardown + spawn-task
# cleanup. If you raise `shutdown-drain-secs`, raise this in lockstep.
TimeoutStopSec=45

# Auto-update / crash-loop rollback / boot-crash self-heal: run `freenet update`
# before systemd restarts the service on ANY non-graceful exit — every
# $EXIT_STATUS except 0 (graceful) and 43 (another instance already running).
# That covers the voluntary update-needed exit 42, the fast-crash code 45
# (#4551), AND the crash codes the listener watchdog never produces: panics
# (101), SIGABRT/SIGKILL/SIGSEGV (134/137/139, which systemd may pass as signal
# NAMES like ABRT/KILL/SEGV), and early-startup errors (1). `freenet update`
# then either steps forward (42 / a newer release) or, for a crash of a
# freshly-installed version still on probation, counts it and — once it keeps
# crashing — rolls back to the previous binary (#4073). `freenet update` is a
# SEPARATE process that can succeed even when `freenet network` crashes at
# startup, preserving the #4549 self-heal.
# This does NOT exempt crash codes from the limiter: ExecStopPost runs AFTER the
# process exits (the '-' prefix means its own result never affects restart) and
# 45 / 1 / 101 / signals stay OUT of SuccessExitStatus below, so a no-fix tight
# loop still trips StartLimitBurst and the unit stops. A `case` (not `&&`/`||`)
# avoids shell-precedence pitfalls. $$EXIT_STATUS is doubled so systemd passes a
# literal $EXIT_STATUS through to sh. {post_stop_env} forwards that status to
# `freenet update` so #4073 can tell a post-stop restart from a manual update
# and classify the crash; an OLD binary (e.g. one we rolled back TO) ignores the
# unknown env var.
ExecStopPost=-/bin/sh -c 'case "$$EXIT_STATUS" in 0|43) ;; *) {post_stop_env}="$$EXIT_STATUS" {binary} update --quiet ;; esac'
# Exit 42 (auto-update) and 43 (another instance) are clean exits, so they are
# not counted as failures — without this, rapid update cycles (exit 42 →
# ExecStopPost → restart) could exhaust the burst limit and kill the service.
# Exit 45 (fast-crash / boot-wedge, #4551) is deliberately NOT whitelisted here:
# it must COUNT toward StartLimitBurst so a tight crash loop trips the [Unit]
# limiter and the unit stops — even though the ExecStopPost above still runs
# `freenet update` for it. Counting (StartLimit) and self-heal (ExecStopPost) are
# independent: the former bounds a no-fix loop, the latter rescues a boot-crash a
# newer release fixes. (44 is the internal bundle-update-staged code, never
# emitted by `freenet network`.)
SuccessExitStatus=42 43
# Exit code 43 = another instance is already running on the port.
# Do NOT restart — the existing instance is healthy.
RestartPreventExitStatus=43

# Logging
# - The node's tracing layer writes its own size-capped, hourly-rotated
#   logs to {log_dir}/freenet.YYYY-MM-DD-HH.log (LOG_RETENTION_HOURS +
#   LOG_DIR_MAX_BYTES; see crates/core/src/tracing.rs).
# - systemd's StandardOutput/StandardError previously appended to a fixed
#   freenet.log / freenet.error.log that the time-based cleanup never
#   pruned (mtime stayed fresh while the file was being written), so they
#   grew without bound on long-running nodes (issue #4251).
# - Routing both to the journal lets journald handle rotation, and panics
#   or pre-tracing-init output remain queryable via
#   `journalctl -u freenet`.
StandardOutput=journal
StandardError=journal
SyslogIdentifier=freenet

# Resource limits to prevent runaway resource consumption
# File descriptors needed for network connections
LimitNOFILE=65536
# Memory limit (2GB soft limit)
MemoryMax=2G
# CPU quota (200% = 2 cores max)
CPUQuota=200%

[Install]
WantedBy=multi-user.target
"#,
        binary = binary_path.display(),
        log_dir = log_dir.display(),
        username = username,
        home = home_dir.display(),
        fast_crash_marker = super::super::auto_update::SYSTEMD_FAST_CRASH_ENV_VAR,
        post_stop_env = super::super::rollback::POST_STOP_EXIT_CODE_ENV_VAR,
    )
}

/// Stop, disable, and remove the Freenet service file. Does not purge data.
/// Returns true if a service was found and removed.
#[cfg(target_os = "linux")]
pub fn stop_and_remove_service(system: bool) -> Result<bool> {
    use std::fs;

    let system_mode = use_system_mode(system);

    let service_path = if system_mode {
        std::path::PathBuf::from(SYSTEM_SERVICE_PATH)
    } else {
        dirs::home_dir()
            .context("Failed to get home directory")?
            .join(".config/systemd/user/freenet.service")
    };

    if !service_path.exists() {
        return Ok(false);
    }

    // Stop the service if running (best-effort, may already be stopped)
    let _stop = systemctl(system_mode, &["stop", "freenet"]);

    // Disable the service (best-effort, may already be disabled)
    let _disable = systemctl(system_mode, &["disable", "freenet"]);

    fs::remove_file(&service_path).context("Failed to remove service file")?;

    // Reload systemd (best-effort, failure is non-fatal during uninstall)
    drop(systemctl(system_mode, &["daemon-reload"]));

    Ok(true)
}

#[cfg(target_os = "linux")]
pub(super) fn uninstall_service(system: bool, purge: bool, keep_data: bool) -> Result<()> {
    stop_and_remove_service(system)?;

    println!("Freenet service uninstalled.");

    if super::purge::should_purge(purge, keep_data)? {
        let system_mode = use_system_mode(system);
        super::purge::purge_data_dirs(system_mode)?;
        println!("All Freenet data, config, and logs removed.");
    }

    Ok(())
}

#[cfg(target_os = "linux")]
pub(super) fn service_status(system: bool) -> Result<()> {
    let system_mode = use_system_mode(system);
    let status = systemctl(system_mode, &["status", "freenet"])?;
    std::process::exit(status.code().unwrap_or(1));
}

#[cfg(target_os = "linux")]
pub(super) fn start_service(system: bool) -> Result<()> {
    let system_mode = use_system_mode(system);
    systemctl_with_hint(system_mode, &["start", "freenet"], "start service")?;
    println!("Freenet service started.");
    println!("Open http://127.0.0.1:7509/ in your browser to view your Freenet dashboard.");
    Ok(())
}

#[cfg(target_os = "linux")]
pub(super) fn stop_service(system: bool) -> Result<()> {
    let system_mode = use_system_mode(system);
    systemctl_with_hint(system_mode, &["stop", "freenet"], "stop service")?;
    println!("Freenet service stopped.");
    Ok(())
}

#[cfg(target_os = "linux")]
pub(super) fn restart_service(system: bool) -> Result<()> {
    let system_mode = use_system_mode(system);
    systemctl_with_hint(system_mode, &["restart", "freenet"], "restart service")?;
    println!("Freenet service restarted.");
    println!("Open http://127.0.0.1:7509/ in your browser to view your Freenet dashboard.");
    Ok(())
}

#[cfg(target_os = "linux")]
pub(super) fn service_logs(error_only: bool) -> Result<()> {
    let log_dir = dirs::home_dir()
        .context("Failed to get home directory")?
        .join(".local/state/freenet");

    let base_name = if error_only {
        "freenet.error"
    } else {
        "freenet"
    };

    super::log_utils::tail_with_rotation(&log_dir, base_name)
}

#[cfg(all(test, target_os = "linux"))]
mod tests {
    use std::path::Path;

    use super::{
        LingerAction, generate_system_service_file, generate_user_service_file, linger_action,
    };

    /// Policy pin (issue #4073): a system service never lingers (it starts at
    /// boot regardless), a user service enables lingering by default so it
    /// survives logout on a headless server, and `--no-linger` keeps a user
    /// service login-scoped. Lingering is the user-service counterpart to a
    /// system service's boot start; without it a `--user` node stops at logout
    /// and silently never auto-updates.
    #[test]
    fn linger_action_matches_policy() {
        // System service: `no_linger` is irrelevant.
        assert_eq!(linger_action(true, false), LingerAction::SystemService);
        assert_eq!(linger_action(true, true), LingerAction::SystemService);
        // User service defaults to enabling lingering.
        assert_eq!(linger_action(false, false), LingerAction::EnableForUser);
        // User service with --no-linger stays login-scoped.
        assert_eq!(linger_action(false, true), LingerAction::SkipForUser);
    }

    /// Byte offset of the section header line (a line whose trimmed content is
    /// exactly `[name]`). Matches by LINE, not a naive substring search, so a
    /// bracketed section name mentioned inside a comment (e.g. "...ignored in
    /// `[Service]`" inside the `[Unit]` section, #4551) is never mistaken for the
    /// real section header.
    fn section_header_offset(unit: &str, name: &str) -> usize {
        let header = format!("[{name}]");
        let mut offset = 0usize;
        for line in unit.split_inclusive('\n') {
            if line.trim() == header {
                return offset;
            }
            offset += line.len();
        }
        panic!("unit must contain a {header} section header line");
    }

    /// Body of the `[name]` section: everything between that section's header line
    /// and the next section header line. Header lines are matched by line (a
    /// comment containing a bracketed name is ignored), so this is robust to
    /// `[Service]`/`[Unit]` being referenced inside comments.
    fn section<'a>(unit: &'a str, name: &str) -> &'a str {
        let header = format!("[{name}]");
        let mut offset = 0usize;
        let mut body_start: Option<usize> = None;
        for line in unit.split_inclusive('\n') {
            let trimmed = line.trim();
            match body_start {
                None => {
                    if trimmed == header {
                        body_start = Some(offset + line.len());
                    }
                }
                Some(start) => {
                    if trimmed.starts_with('[') && trimmed.ends_with(']') && !trimmed.contains(' ')
                    {
                        return &unit[start..offset];
                    }
                }
            }
            offset += line.len();
        }
        match body_start {
            Some(start) => &unit[start..],
            None => panic!("unit must contain {header} section"),
        }
    }

    fn assert_start_limit_directives_are_in_unit_section(unit_name: &str, unit: &str) {
        let unit_header = section_header_offset(unit, "Unit");
        let service_header = section_header_offset(unit, "Service");
        let unit_section = section(unit, "Unit");
        let service_section = section(unit, "Service");

        for directive in ["StartLimitBurst=5", "StartLimitIntervalSec=120"] {
            let occurrences = unit.lines().filter(|line| line.trim() == directive).count();
            assert_eq!(
                occurrences, 1,
                "{unit_name} unit must emit exactly one {directive}"
            );

            let directive_offset = unit
                .find(directive)
                .unwrap_or_else(|| panic!("{unit_name} unit must contain {directive}"));
            assert!(
                directive_offset > unit_header && directive_offset < service_header,
                "{directive} must appear after [Unit] and before [Service] in {unit_name} unit"
            );
            assert!(
                unit_section.lines().any(|line| line.trim() == directive),
                "{directive} must be in [Unit] for {unit_name} unit"
            );
            assert!(
                !service_section.lines().any(|line| line.trim() == directive),
                "{directive} must not be in [Service] for {unit_name} unit"
            );
        }
    }

    #[test]
    fn user_unit_places_start_limit_directives_in_unit_section() {
        let unit = generate_user_service_file(
            Path::new("/usr/local/bin/freenet"),
            Path::new("/home/test/.local/state/freenet"),
        );

        assert_start_limit_directives_are_in_unit_section("user", &unit);
    }

    #[test]
    fn system_unit_places_start_limit_directives_in_unit_section() {
        let unit = generate_system_service_file(
            Path::new("/usr/local/bin/freenet"),
            Path::new("/home/test/.local/state/freenet"),
            "testuser",
            Path::new("/home/test"),
        );

        assert_start_limit_directives_are_in_unit_section("system", &unit);
    }

    #[test]
    fn systemd_units_have_restart_backoff() {
        // #4073: both units must add escalating restart backoff
        // (RestartSteps + RestartMaxDelaySec) on top of RestartSec so a residual
        // loop that doesn't trip StartLimitBurst still slows down (fewer gateway
        // reconnects). These are systemd >= 254 directives that older systemd
        // ignores; we only assert they are emitted in [Service].
        let user_unit = generate_user_service_file(
            Path::new("/usr/local/bin/freenet"),
            Path::new("/home/test/.local/state/freenet"),
        );
        let system_unit = generate_system_service_file(
            Path::new("/usr/local/bin/freenet"),
            Path::new("/home/test/.local/state/freenet"),
            "testuser",
            Path::new("/home/test"),
        );
        for (name, unit) in [("user", &user_unit), ("system", &system_unit)] {
            let service = section(unit, "Service");
            assert!(
                service.lines().any(|l| l.trim() == "RestartSec=10"),
                "{name} unit must keep the base RestartSec"
            );
            assert!(
                service.lines().any(|l| l.trim() == "RestartSteps=10"),
                "{name} unit must emit RestartSteps in [Service]"
            );
            assert!(
                service
                    .lines()
                    .any(|l| l.trim() == "RestartMaxDelaySec=300"),
                "{name} unit must emit RestartMaxDelaySec in [Service]"
            );
        }
    }

    #[test]
    fn systemd_units_keep_auto_update_success_exit_status() {
        let user_unit = generate_user_service_file(
            Path::new("/usr/local/bin/freenet"),
            Path::new("/home/test/.local/state/freenet"),
        );
        let system_unit = generate_system_service_file(
            Path::new("/usr/local/bin/freenet"),
            Path::new("/home/test/.local/state/freenet"),
            "testuser",
            Path::new("/home/test"),
        );

        assert!(user_unit.contains("SuccessExitStatus=42"));
        assert!(system_unit.contains("SuccessExitStatus=42"));
    }

    #[test]
    fn systemd_units_pass_node_exit_code_to_post_stop_update() {
        // #4073 crash-loop auto-rollback: ExecStopPost must (a) fire on ANY
        // non-graceful exit (every status except 0 and 43) so panics / signals /
        // early errors are covered, and (b) forward the node's stop status to
        // `freenet update` via the env var so the updater can classify the
        // crash. systemd escapes `$$` to a literal `$`.
        let env = super::super::super::rollback::POST_STOP_EXIT_CODE_ENV_VAR;
        // Broadened guard: skip only 0 and 43; run update (with the status
        // forwarded) for everything else.
        let expected = format!(r#"case "$$EXIT_STATUS" in 0|43) ;; *) {env}="$$EXIT_STATUS""#);
        let user_unit = generate_user_service_file(
            Path::new("/usr/local/bin/freenet"),
            Path::new("/home/test/.local/state/freenet"),
        );
        let system_unit = generate_system_service_file(
            Path::new("/usr/local/bin/freenet"),
            Path::new("/home/test/.local/state/freenet"),
            "testuser",
            Path::new("/home/test"),
        );
        for (name, unit) in [("user", &user_unit), ("system", &system_unit)] {
            assert!(
                unit.contains(&expected),
                "{name} unit must fire the post-stop update on any non-graceful exit and \
                 forward {env} (#4073)"
            );
            // Regression guard: the OLD narrow `42|45)` form must be gone.
            assert!(
                !unit.contains("42|45)"),
                "{name} unit still uses the narrow 42|45 ExecStopPost guard, missing \
                 panic/signal/early-error crashes (#4073 M1)"
            );
        }
    }
}