Skip to main content

pitchfork_cli/proxy/
hostname.rs

1//! Automatic hostnames for daemons.
2//!
3//! Every daemon that configures a `port` gets a hostname derived from where its
4//! configuration lives, resolved right to left against a registry of projects:
5//!
6//! ```text
7//! <daemon>.<worktree>.<project>.<tld>   daemon in a linked git worktree
8//! <daemon>.<project>.<tld>              daemon in the primary checkout
9//! <worktree>.<project>.<tld>            stack page (not a daemon)
10//! <project>.<tld>                       project page (not a daemon)
11//! ```
12//!
13//! Labels are DNS-safe lowercase. The project label is the label its
14//! registration names (`pitchfork config add --label`), else the namespace's
15//! project name when the project declares one explicitly, otherwise the
16//! directory name of the primary checkout. The worktree label is the linked worktree's
17//! directory name unless the config sets `worktree_label`.
18
19use crate::config_types::ProxyConfig;
20use crate::daemon_id::DaemonId;
21use crate::pitchfork_toml::{PitchforkToml, PitchforkTomlDaemon};
22use std::collections::HashMap;
23use std::path::{Path, PathBuf};
24
25/// Maximum length of a single DNS label (RFC 1035).
26const MAX_LABEL_LEN: usize = 63;
27
28/// Maximum length of a full host name (RFC 1035), which the labels share with
29/// the configured TLD.
30pub(crate) const MAX_HOSTNAME_LEN: usize = 253;
31
32/// Whether a hostname's labels still leave room for the configured TLD.
33///
34/// Three maximum-length labels plus a long `proxy.tld` can exceed what DNS
35/// accepts, and a name nothing can resolve is worse than no name at all. Every
36/// surface asks this — generation, listing and routing — so they agree on
37/// whether such a hostname exists.
38pub fn hostname_fits(host: &str) -> bool {
39    let s = crate::settings::settings();
40    host.len() + 1 + crate::proxy::effective_tld(&s).len() <= MAX_HOSTNAME_LEN
41}
42
43/// Convert an arbitrary name into a DNS-safe lowercase label.
44///
45/// ASCII letters are lowercased, digits are kept, and every other character
46/// becomes `-`. Runs of `-` collapse, leading and trailing `-` are trimmed and
47/// the result is truncated to 63 characters. Returns `None` when nothing
48/// usable is left, in which case the caller has no hostname to offer.
49pub fn sanitize_label(input: &str) -> Option<String> {
50    let mut out = String::with_capacity(input.len());
51    for c in input.chars() {
52        if c.is_ascii_alphanumeric() {
53            out.push(c.to_ascii_lowercase());
54        } else if !out.ends_with('-') {
55            out.push('-');
56        }
57    }
58    let trimmed = out.trim_matches('-');
59    let trimmed = if trimmed.len() > MAX_LABEL_LEN {
60        trimmed[..MAX_LABEL_LEN].trim_end_matches('-')
61    } else {
62        trimmed
63    };
64    (!trimmed.is_empty()).then(|| trimmed.to_string())
65}
66
67// ─── checkout detection ──────────────────────────────────────────────────────
68
69/// Where a directory sits in a git repository.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct Checkout {
72    /// The primary checkout: the parent of the git common directory.
73    pub primary: PathBuf,
74    /// The linked worktree's own root, when the directory is inside one.
75    pub worktree: Option<PathBuf>,
76}
77
78impl Checkout {
79    /// The root of the checkout the directory belongs to.
80    pub fn root(&self) -> &Path {
81        self.worktree.as_deref().unwrap_or(&self.primary)
82    }
83}
84
85/// Parse the `gitdir:` pointer of a linked worktree's `.git` file.
86///
87/// Returns the linked worktree's administrative directory
88/// (`<common>/worktrees/<name>`), resolved against `dir` when relative.
89fn parse_gitdir_pointer(dir: &Path, content: &str) -> Option<PathBuf> {
90    let target = content
91        .lines()
92        .find_map(|line| line.trim().strip_prefix("gitdir:"))?
93        .trim();
94    if target.is_empty() {
95        return None;
96    }
97    let path = PathBuf::from(target);
98    let path = if path.is_absolute() {
99        path
100    } else {
101        dir.join(path)
102    };
103    Some(normalize(&path))
104}
105
106/// Resolve `..` and `.` components without touching the filesystem, so the
107/// result is stable for directories git has already removed.
108fn normalize(path: &Path) -> PathBuf {
109    let mut out = PathBuf::new();
110    for part in path.components() {
111        match part {
112            std::path::Component::CurDir => {}
113            std::path::Component::ParentDir => {
114                out.pop();
115            }
116            part => out.push(part.as_os_str()),
117        }
118    }
119    out
120}
121
122/// Canonicalize as much of a path as exists, keeping the rest as written.
123///
124/// Every checkout path pitchfork compares goes through here, so the same
125/// directory has one spelling no matter how it was reached: through a symlink,
126/// as `/var` instead of `/private/var` on macOS, or with the `\\?\` prefix
127/// Windows adds. A leaf that no longer exists — a deleted working directory, a
128/// dangling symlink — still resolves against its nearest surviving ancestor, so
129/// a daemon whose directory disappeared keeps the checkout it started in.
130fn canonicalize_best_effort(path: &Path) -> PathBuf {
131    let path = normalize(path);
132    let mut suffix: Vec<std::ffi::OsString> = Vec::new();
133    let mut current = path.as_path();
134    loop {
135        if let Ok(resolved) = current.canonicalize() {
136            let mut out = dunce::simplified(&resolved).to_path_buf();
137            for part in suffix.iter().rev() {
138                out.push(part);
139            }
140            return out;
141        }
142        let Some(parent) = current.parent() else {
143            return path;
144        };
145        match current.file_name() {
146            Some(name) => suffix.push(name.to_os_string()),
147            // A path with no file name (a bare root) cannot be walked further.
148            None => return path,
149        }
150        current = parent;
151    }
152}
153
154/// Split `<common>/worktrees/<name>` into the primary checkout directory.
155///
156/// The common directory is the parent of `worktrees/`, and the primary
157/// checkout is its parent. Anything else (a submodule's `.git/modules/...`
158/// pointer, for instance) is not a linked worktree.
159fn primary_from_worktree_gitdir(gitdir: &Path) -> Option<PathBuf> {
160    let worktrees_dir = gitdir.parent()?;
161    if worktrees_dir.file_name()? != "worktrees" {
162        return None;
163    }
164    let common = worktrees_dir.parent()?;
165    // A bare repository has no primary checkout: its common directory is the
166    // repository itself (`repo.git`), not a `.git` inside a working tree.
167    // Treating its parent as the project would group every bare repository in
168    // that directory under one label, so such a worktree stands on its own.
169    if common.file_name()? != ".git" {
170        return None;
171    }
172    // The pointer is whatever git wrote, so it needs the same resolution as a
173    // path the caller supplied before it can be compared with one.
174    common.parent().map(canonicalize_best_effort)
175}
176
177/// The root of the checkout a directory belongs to, canonicalized.
178///
179/// Used to attribute a running daemon to one checkout. Comparing paths
180/// lexically would put a worktree nested inside its primary checkout (say
181/// `.worktrees/feature`) in the primary, and would miss a daemon whose
182/// directory reaches the same place through a symlink.
183pub fn checkout_root_of(dir: &Path) -> PathBuf {
184    let checkout = detect_checkout(dir);
185    checkout.root().to_path_buf()
186}
187
188/// Locate the checkout containing `dir` by walking up to the nearest `.git`.
189///
190/// A `.git` directory marks the primary checkout. A `.git` file whose
191/// `gitdir:` points into `<common>/worktrees/<name>` marks a linked worktree,
192/// whose primary checkout is the parent of the common directory. When no
193/// `.git` is found the directory is treated as its own primary checkout, so
194/// projects that are not git repositories still get a hostname.
195pub fn detect_checkout(dir: &Path) -> Checkout {
196    let start = canonicalize_best_effort(dir);
197    for current in start.ancestors() {
198        let git = current.join(".git");
199        if git.is_dir() {
200            return Checkout {
201                primary: current.to_path_buf(),
202                worktree: None,
203            };
204        }
205        if git.is_file() {
206            let primary = std::fs::read_to_string(&git)
207                .ok()
208                .and_then(|content| parse_gitdir_pointer(current, &content))
209                .and_then(|gitdir| primary_from_worktree_gitdir(&gitdir));
210            return match primary {
211                Some(primary) if primary != current => Checkout {
212                    primary,
213                    worktree: Some(current.to_path_buf()),
214                },
215                // A `.git` file that is not a linked worktree pointer (a
216                // submodule, say) still marks the root of its own checkout.
217                _ => Checkout {
218                    primary: current.to_path_buf(),
219                    worktree: None,
220                },
221            };
222        }
223    }
224    Checkout {
225        primary: start,
226        worktree: None,
227    }
228}
229
230// ─── labels ──────────────────────────────────────────────────────────────────
231
232/// Check that a label a registration names is usable as written.
233///
234/// The label is used verbatim, so it must already be what [`sanitize_label`]
235/// would produce, and `<label>.<tld>` (the project page) must fit the DNS
236/// limit. Returns a message saying what to change otherwise.
237pub fn validate_registered_label(label: &str) -> Result<(), String> {
238    let Some(clean) = sanitize_label(label) else {
239        return Err(format!(
240            "label '{label}' has no usable characters; use lowercase letters, digits and hyphens"
241        ));
242    };
243    if label.len() > MAX_LABEL_LEN {
244        return Err(format!(
245            "label '{label}' is {} characters; a hostname label is limited to {MAX_LABEL_LEN}",
246            label.len()
247        ));
248    }
249    if clean != label {
250        return Err(format!(
251            "label '{label}' is not a valid hostname label; use lowercase letters, digits and \
252             single hyphens between them (for example '{clean}')"
253        ));
254    }
255    if !hostname_fits(label) {
256        return Err(format!(
257            "label '{label}' plus the configured proxy.tld is over the {MAX_HOSTNAME_LEN}-byte DNS limit"
258        ));
259    }
260    Ok(())
261}
262
263/// The project label for a primary checkout.
264///
265/// A registration that names a label (`pitchfork config add --label`) wins:
266/// a tool that registers under a generated namespace, kept only to make daemon
267/// IDs unique, says here what the hostname should be. Otherwise a project
268/// registered or configured with an explicit namespace uses that name, and
269/// failing that the directory name of the primary checkout is used.
270pub fn project_label(primary: &Path) -> Option<String> {
271    let registered = crate::extra_configs::label_for_checkout(primary);
272    let explicit = PitchforkToml::project_namespace_override(primary)
273        .ok()
274        .flatten()
275        .or_else(|| crate::extra_configs::namespace_for_dir(primary));
276    pick_project_label(primary, registered.as_deref(), explicit.as_deref())
277}
278
279/// The label a registration names for a primary checkout, when it is usable.
280///
281/// This is the part of [`project_label`] that a `--label` registration
282/// controls, as opposed to a namespace or a directory name.
283fn registered_project_label(primary: &Path) -> Option<String> {
284    crate::extra_configs::label_for_checkout(primary).and_then(|l| sanitize_label(&l))
285}
286
287/// The precedence behind [`project_label`], separated from the lookups.
288///
289/// A registered label that sanitizes to nothing (a hand-edited registry) is
290/// ignored rather than hiding the project.
291fn pick_project_label(
292    primary: &Path,
293    registered: Option<&str>,
294    explicit_namespace: Option<&str>,
295) -> Option<String> {
296    if let Some(label) = registered.and_then(sanitize_label) {
297        return Some(label);
298    }
299    match explicit_namespace {
300        Some(ns) => sanitize_label(ns),
301        None => sanitize_label(&primary.file_name()?.to_string_lossy()),
302    }
303}
304
305/// The worktree label for a linked worktree directory.
306///
307/// Defaults to the directory name, overridden by the `worktree_label` key in
308/// that worktree's own configuration files.
309pub fn worktree_label(worktree: &Path) -> Option<String> {
310    if let Some(label) = PitchforkToml::project_worktree_label(worktree) {
311        return sanitize_label(&label);
312    }
313    sanitize_label(&worktree.file_name()?.to_string_lossy())
314}
315
316/// The daemon label for a daemon config: its name, or the `proxy` override.
317pub fn daemon_label(name: &str, proxy: Option<&ProxyConfig>) -> Option<String> {
318    if proxy.is_some_and(ProxyConfig::is_disabled) {
319        return None;
320    }
321    match proxy.and_then(ProxyConfig::label) {
322        Some(label) => sanitize_label(label),
323        None => sanitize_label(name),
324    }
325}
326
327/// Join hostname labels left to right, omitting the worktree when absent.
328fn join_labels(daemon: &str, worktree: Option<&str>, project: &str) -> String {
329    match worktree {
330        Some(wt) => format!("{daemon}.{wt}.{project}"),
331        None => format!("{daemon}.{project}"),
332    }
333}
334
335/// The automatic hostname (without TLD) for a daemon, e.g. `api.fix-1.myproj`.
336///
337/// Every label is checked against the same project the proxy routes with, so a
338/// label the proxy refuses as ambiguous is never advertised as a URL. Returns
339/// `None` when the daemon configures no port, opted out with `proxy = false`,
340/// no label could be derived, or any of its labels collides with another
341/// daemon, worktree or project.
342pub fn auto_host_for_daemon(id: &DaemonId, config: &PitchforkTomlDaemon) -> Option<String> {
343    config.port.as_ref()?;
344    let daemon = daemon_label(id.name(), config.proxy.as_ref())?;
345    let path = config.path.as_deref()?;
346    // A daemon declared in a global config belongs to no project, and the
347    // config's own directory is not one: it would put daemons under a label
348    // like `pitchfork`. Those daemons are reachable through a legacy slug.
349    if crate::pitchfork_toml::is_global_config(path) {
350        return None;
351    }
352    let base = crate::pitchfork_toml::project_dir_for_config(path)?;
353    let checkout = detect_checkout(&base);
354    let project_label = project_label(&checkout.primary)?;
355    if project_label_is_ambiguous(&project_label, &checkout.primary) {
356        return None;
357    }
358
359    let project = project_hosts_for(&checkout.primary, &project_label)?;
360    let (hosts, worktree) = match &checkout.worktree {
361        Some(dir) => {
362            // A worktree missing from the project either collided with another
363            // worktree's label or failed to load; either way it is not routed.
364            let label = worktree_label(dir)?;
365            (project.worktrees.get(&label)?, Some(label))
366        }
367        None => (&project.primary, None),
368    };
369    if !routes_label_to(hosts, &daemon, id) {
370        return None;
371    }
372
373    let host = join_labels(&daemon, worktree.as_deref(), &project_label);
374    if !hostname_fits(&host) {
375        log::warn!(
376            "'{host}' plus the configured proxy.tld is over the {MAX_HOSTNAME_LEN}-byte DNS \
377             limit, so no hostname is assigned. Shorten the project, worktree or daemon name, \
378             or use a shorter proxy.tld."
379        );
380        return None;
381    }
382    Some(host)
383}
384
385/// Whether a checkout routes `label` to this daemon, rather than to nothing or
386/// to another one.
387///
388/// The namespace counts as well as the name: a project nested in a monorepo
389/// shares the repository's checkout, whose hostnames are those of the root's
390/// daemons, so the nested `frontend/api` must not take `repo/api`'s URL.
391fn routes_label_to(hosts: &CheckoutHosts, label: &str, id: &DaemonId) -> bool {
392    hosts.namespace == id.namespace()
393        && hosts
394            .daemons
395            .get(label)
396            .is_some_and(|d| d.name == id.name())
397}
398
399/// The hostname to advertise for a daemon: a legacy `[slugs]` entry when one
400/// exists, otherwise the automatic hostname.
401///
402/// Legacy slugs win because the proxy resolves them first, so this never
403/// advertises an address that routes somewhere else.
404pub fn host_for_daemon(
405    id: &DaemonId,
406    config: Option<&PitchforkTomlDaemon>,
407    global_slugs: &indexmap::IndexMap<String, crate::pitchfork_toml::SlugEntry>,
408) -> Option<String> {
409    // Slugs are registered without a length check, and one too long for the
410    // configured TLD is a name the proxy will not route, so it is not offered
411    // as a URL either.
412    if let Some(slug) = PitchforkToml::find_slug_for_daemon_in_registry(id, global_slugs) {
413        if hostname_fits(&slug) {
414            return Some(slug);
415        }
416        log::warn!(
417            "Slug '{slug}' plus the configured proxy.tld is over the \
418             {MAX_HOSTNAME_LEN}-byte DNS limit, so it is not advertised for {id}."
419        );
420    }
421    auto_host_for_daemon(id, config?)
422}
423
424// ─── registry ────────────────────────────────────────────────────────────────
425
426/// One checkout of a project and the daemons reachable within it.
427#[derive(Debug, Clone)]
428pub struct CheckoutHosts {
429    /// Root directory of this checkout.
430    pub dir: PathBuf,
431    /// Namespace the checkout's daemons belong to.
432    pub namespace: String,
433    /// Daemon label → the daemon it names.
434    pub daemons: HashMap<String, DaemonHost>,
435}
436
437/// A daemon reachable under a checkout's hostname, and how its TLS is served.
438///
439/// The TLS settings are captured here, while the checkout's config is being
440/// read anyway, rather than re-read per connection. That keeps the routing
441/// decision and the hostname that carries it from ever coming from different
442/// reads of the config: a `proxy_tls = "passthrough"` daemon cannot be listed
443/// with its mode missing and be terminated with the proxy's certificate
444/// because its config happened to be unreadable at that moment.
445#[derive(Debug, Clone, PartialEq, Eq)]
446pub struct DaemonHost {
447    /// The daemon's name within its namespace.
448    pub name: String,
449    /// `proxy_tls` as the checkout's config had it, `None` when unset.
450    pub proxy_tls: Option<crate::config_types::ProxyTlsMode>,
451    /// `proxy_tls_port`, or its shorter `proxy_port` spelling.
452    pub proxy_tls_port: Option<u16>,
453}
454
455impl CheckoutHosts {
456    /// Load the routable daemons of one checkout, along with any collisions
457    /// found while doing so.
458    ///
459    /// Two daemons whose labels reduce to the same string are both dropped:
460    /// keeping either one would route half the requests to a daemon the user
461    /// did not name, and which of the two won would depend on config order.
462    fn load(dir: &Path) -> Option<(Self, Vec<String>)> {
463        let namespace = PitchforkToml::namespace_for_dir(dir).ok()?;
464        let pt = PitchforkToml::all_merged_from(dir).ok()?;
465        let mut daemons: HashMap<String, DaemonHost> = HashMap::new();
466        let mut errors = Vec::new();
467        let mut colliding: Vec<String> = Vec::new();
468        for (id, config) in &pt.daemons {
469            if id.namespace() != namespace || config.port.is_none() {
470                continue;
471            }
472            let Some(label) = daemon_label(id.name(), config.proxy.as_ref()) else {
473                continue;
474            };
475            match daemons.get(&label) {
476                Some(other) => {
477                    errors.push(format!(
478                        "daemon hostname label '{label}' in {} is claimed by both '{other}' and \
479                         '{name}'. Rename one of them, or set `proxy = \"<label>\"` on one.",
480                        dir.display(),
481                        other = other.name,
482                        name = id.name(),
483                    ));
484                    colliding.push(label);
485                }
486                None => {
487                    daemons.insert(
488                        label,
489                        DaemonHost {
490                            name: id.name().to_string(),
491                            proxy_tls: config.proxy_tls,
492                            proxy_tls_port: config.effective_proxy_tls_port(),
493                        },
494                    );
495                }
496            }
497        }
498        for label in colliding {
499            daemons.remove(&label);
500        }
501        Some((
502            Self {
503                dir: dir.to_path_buf(),
504                namespace,
505                daemons,
506            },
507            errors,
508        ))
509    }
510
511    /// Sorted daemon labels, for listings and error pages.
512    pub fn labels(&self) -> Vec<String> {
513        let mut labels: Vec<String> = self.daemons.keys().cloned().collect();
514        labels.sort();
515        labels
516    }
517}
518
519/// A project and its checkouts, keyed by hostname label.
520#[derive(Debug, Clone)]
521pub struct ProjectHosts {
522    pub label: String,
523    pub primary: CheckoutHosts,
524    /// Worktree label → checkout.
525    pub worktrees: HashMap<String, CheckoutHosts>,
526}
527
528impl ProjectHosts {
529    /// Every checkout of this project: its primary and each linked worktree.
530    pub fn checkouts(&self) -> impl Iterator<Item = &CheckoutHosts> {
531        std::iter::once(&self.primary).chain(self.worktrees.values())
532    }
533
534    /// Sorted worktree labels.
535    pub fn worktree_labels(&self) -> Vec<String> {
536        let mut labels: Vec<String> = self.worktrees.keys().cloned().collect();
537        labels.sort();
538        labels
539    }
540}
541
542/// All projects the proxy can route to, keyed by project label.
543#[derive(Debug, Clone, Default)]
544pub struct HostRegistry {
545    pub projects: HashMap<String, ProjectHosts>,
546    /// Label collisions found while loading, reported to the user as-is.
547    pub errors: Vec<String>,
548}
549
550/// What a hostname resolves to.
551#[derive(Debug, Clone, PartialEq, Eq)]
552pub enum HostTarget {
553    /// A daemon in one of the project's checkouts.
554    Daemon {
555        project: String,
556        worktree: Option<String>,
557        dir: PathBuf,
558        namespace: String,
559        daemon: String,
560        /// `proxy_tls` as the checkout's config had it when the registry was
561        /// built, so routing and the hostname come from one read.
562        proxy_tls: Option<crate::config_types::ProxyTlsMode>,
563        /// `proxy_tls_port`, from the same read.
564        proxy_tls_port: Option<u16>,
565    },
566    /// `<project>.<tld>` — reserved for the project page.
567    ProjectPage { project: String },
568    /// `<worktree>.<project>.<tld>` — reserved for the stack page.
569    WorktreePage { project: String, worktree: String },
570    /// The rightmost label is not a known project.
571    UnknownProject { known: Vec<String> },
572    /// The project is known but the daemon label is not.
573    UnknownDaemon {
574        project: String,
575        worktree: Option<String>,
576        known: Vec<String>,
577    },
578}
579
580/// Group a project's worktree checkouts by label.
581///
582/// Two worktrees that reduce to the same label are both dropped and reported:
583/// routing one of them would answer half the requests with the other's content,
584/// which is worse than not routing the label at all.
585fn group_worktrees(
586    project: &str,
587    found: Vec<(String, CheckoutHosts)>,
588) -> (HashMap<String, CheckoutHosts>, Vec<String>) {
589    let mut kept: HashMap<String, CheckoutHosts> = HashMap::new();
590    let mut errors = Vec::new();
591    let mut colliding: Vec<String> = Vec::new();
592    for (label, hosts) in found {
593        match kept.get(&label) {
594            Some(existing) if existing.dir == hosts.dir => continue,
595            Some(existing) => {
596                errors.push(format!(
597                    "worktree label '{label}' in project '{project}' is claimed by two \
598                     directories: {} and {}. Set `worktree_label` in one of them.",
599                    existing.dir.display(),
600                    hosts.dir.display(),
601                ));
602                colliding.push(label);
603            }
604            None => {
605                kept.insert(label, hosts);
606            }
607        }
608    }
609    for label in colliding {
610        kept.remove(&label);
611    }
612    (kept, errors)
613}
614
615/// How long a project's worktree discovery stays cached.
616///
617/// `pitchfork list` derives a hostname for every daemon it prints, and each one
618/// would otherwise re-enumerate the project's worktrees. Only the enumeration
619/// is cached: configuration is re-read every time, so a label that starts
620/// colliding stops being advertised at once, and only a brand-new worktree
621/// directory can take up to this long to appear.
622const WORKTREE_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(2);
623
624struct WorktreeCache {
625    entries: HashMap<PathBuf, (std::time::Instant, std::sync::Arc<Vec<PathBuf>>)>,
626}
627
628static WORKTREE_CACHE: once_cell::sync::Lazy<std::sync::Mutex<WorktreeCache>> =
629    once_cell::sync::Lazy::new(|| {
630        std::sync::Mutex::new(WorktreeCache {
631            entries: HashMap::new(),
632        })
633    });
634
635/// The linked worktree roots of a project, cached briefly.
636fn cached_worktree_dirs(primary: &Path) -> std::sync::Arc<Vec<PathBuf>> {
637    let now = std::time::Instant::now();
638    {
639        let cache = WORKTREE_CACHE.lock().unwrap_or_else(|e| e.into_inner());
640        if let Some((expires_at, dirs)) = cache.entries.get(primary)
641            && now < *expires_at
642        {
643            return std::sync::Arc::clone(dirs);
644        }
645    } // lock released before any I/O
646
647    let dirs = std::sync::Arc::new(worktree_dirs(primary));
648
649    let mut cache = WORKTREE_CACHE.lock().unwrap_or_else(|e| e.into_inner());
650    cache.entries.retain(|_, (expires_at, _)| now < *expires_at);
651    cache.entries.insert(
652        primary.to_path_buf(),
653        (now + WORKTREE_CACHE_TTL, std::sync::Arc::clone(&dirs)),
654    );
655    dirs
656}
657
658/// Report a configuration problem once per distinct message.
659///
660/// A label collision persists until someone renames something, while the
661/// registry behind it is rebuilt every couple of seconds and on every CLI
662/// invocation. Logging each rebuild would fill the supervisor log with the same
663/// line; the user needs to read it once.
664pub fn warn_once(message: &str) {
665    static SEEN: once_cell::sync::Lazy<std::sync::Mutex<std::collections::HashSet<String>>> =
666        once_cell::sync::Lazy::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
667
668    let mut seen = SEEN.lock().unwrap_or_else(|e| e.into_inner());
669    // Distinct messages are bounded by the configuration, but a pathological
670    // one should not grow the set forever.
671    if seen.len() > 256 {
672        seen.clear();
673    }
674    if seen.insert(message.to_string()) {
675        log::warn!("{message}");
676    }
677}
678
679/// Directories that may contain a project pitchfork knows about.
680///
681/// Only persisted knowledge counts: the namespace registry, the legacy slug
682/// registry, and the directories of daemons in the state file. The current
683/// directory is deliberately absent, because it differs between the supervisor
684/// and each CLI invocation, and a registry that depended on it would let the
685/// CLI advertise a hostname the proxy does not route — or refuse one it does.
686fn candidate_dirs() -> Vec<PathBuf> {
687    let mut candidates: Vec<PathBuf> = Vec::new();
688    for (_, entry) in PitchforkToml::read_global_namespaces() {
689        candidates.push(entry.dir);
690    }
691    for (_, entry) in PitchforkToml::read_global_slugs() {
692        if let Some(dir) = entry.resolve_dir() {
693            candidates.push(dir);
694        }
695    }
696    if let Ok(state) = crate::state_file::StateFile::read(&*crate::env::PITCHFORK_STATE_FILE) {
697        for daemon in state.daemons.values() {
698            if let Some(dir) = &daemon.dir {
699                candidates.push(dir.clone());
700            }
701        }
702    }
703    candidates
704}
705
706/// Whether another known project claims the same project label.
707///
708/// Such a label routes to neither project, so nothing may advertise a URL
709/// under it. This reads configuration files but never enumerates worktrees,
710/// which keeps it cheap enough for the per-daemon display paths.
711fn project_label_is_ambiguous(label: &str, primary: &Path) -> bool {
712    let mut seen: Vec<PathBuf> = Vec::new();
713    for dir in candidate_dirs() {
714        if !dir.exists() {
715            continue;
716        }
717        let other = detect_checkout(&dir).primary;
718        if other == primary || seen.contains(&other) {
719            continue;
720        }
721        seen.push(other.clone());
722        if project_label(&other).as_deref() == Some(label) {
723            return true;
724        }
725    }
726    false
727}
728
729/// The error for two projects that reduce to one label.
730///
731/// When either label came from a registration, a registered label outranks a
732/// namespace, so setting a `namespace` cannot resolve the clash; the advice is
733/// to register a different label instead.
734fn project_label_collision_message(
735    label: &str,
736    first: &Path,
737    second: &Path,
738    from_registration: bool,
739) -> String {
740    let advice = if from_registration {
741        "Register a different label for one of them with \
742         `pitchfork config add <file> --label <other>`."
743    } else {
744        "Set a distinct top-level `namespace` in one of them."
745    };
746    format!(
747        "project label '{label}' is claimed by two directories: {} and {}. {advice}",
748        first.display(),
749        second.display(),
750    )
751}
752
753/// Linked worktree roots recorded in a checkout's git common directory.
754///
755/// Each `<common>/worktrees/<name>/gitdir` holds the path of that worktree's
756/// own `.git` file, whose parent is the worktree root. Reading them directly
757/// costs no subprocess and still works when `git` is unavailable, so it
758/// complements the `git worktree list` discovery that also covers jj.
759fn linked_worktree_dirs(primary: &Path) -> Vec<PathBuf> {
760    let Ok(entries) = std::fs::read_dir(primary.join(".git/worktrees")) else {
761        return vec![];
762    };
763    entries
764        .filter_map(|entry| {
765            let gitdir = std::fs::read_to_string(entry.ok()?.path().join("gitdir")).ok()?;
766            let git_file = PathBuf::from(gitdir.trim());
767            git_file.parent().map(Path::to_path_buf)
768        })
769        .collect()
770}
771
772/// Every linked worktree root of a project, from both discovery sources.
773///
774/// `git worktree list` also covers jj workspaces, and the `gitdir` pointers
775/// cover repositories where `git` is unavailable or errors.
776fn worktree_dirs(primary: &Path) -> Vec<PathBuf> {
777    if !crate::settings::settings().general.worktree {
778        return vec![];
779    }
780    let mut dirs: Vec<PathBuf> = Vec::new();
781    let found = crate::proxy::worktree::discover_worktrees(primary)
782        .into_iter()
783        .map(|entry| entry.path)
784        .chain(linked_worktree_dirs(primary));
785    for path in found {
786        let Some(wt_dir) = detect_checkout(&path).worktree else {
787            continue; // the primary checkout itself
788        };
789        if !dirs.contains(&wt_dir) {
790            dirs.push(wt_dir);
791        }
792    }
793    dirs
794}
795
796/// Build one project's checkouts: its primary and every linked worktree.
797///
798/// Returns the project together with the label collisions found inside it.
799/// Configuration is read fresh on every call, so a collision introduced by an
800/// edit takes effect immediately; only `worktree_dirs` may be cached.
801fn build_project_hosts(
802    primary: &Path,
803    label: &str,
804    worktrees: &[PathBuf],
805) -> Option<(ProjectHosts, Vec<String>)> {
806    let (primary_hosts, mut errors) = CheckoutHosts::load(primary)?;
807    let mut project = ProjectHosts {
808        label: label.to_string(),
809        primary: primary_hosts,
810        worktrees: HashMap::new(),
811    };
812
813    let mut found: Vec<(String, CheckoutHosts)> = Vec::new();
814    for wt_dir in worktrees {
815        let (Some(wt_label), Some((hosts, wt_errors))) =
816            (worktree_label(wt_dir), CheckoutHosts::load(wt_dir))
817        else {
818            continue;
819        };
820        errors.extend(wt_errors);
821        found.push((wt_label, hosts));
822    }
823    let (worktrees, wt_errors) = group_worktrees(label, found);
824    project.worktrees = worktrees;
825    errors.extend(wt_errors);
826
827    Some((project, errors))
828}
829
830/// Build a project the way the proxy routes it, reusing a brief cache of its
831/// worktree enumeration.
832fn project_hosts_for(primary: &Path, label: &str) -> Option<ProjectHosts> {
833    let worktrees = cached_worktree_dirs(primary);
834    let (project, errors) = build_project_hosts(primary, label, &worktrees)?;
835    for err in errors {
836        warn_once(&err);
837    }
838    Some(project)
839}
840
841impl HostRegistry {
842    /// Build the registry from every project pitchfork knows about.
843    ///
844    /// Candidate directories come from the current directory, the namespace
845    /// registry, the legacy slug registry, and the directories of daemons in
846    /// the state file. Each is mapped to its primary checkout, whose linked
847    /// worktrees are then discovered.
848    ///
849    /// A project the supervisor has never seen — never started, never
850    /// registered — is therefore not routable from another directory yet.
851    pub fn build() -> Self {
852        Self::from_dirs(&candidate_dirs())
853    }
854
855    /// Build the registry from an explicit list of project directories.
856    pub fn from_dirs(dirs: &[PathBuf]) -> Self {
857        // Collapse the candidates to distinct primary checkouts.
858        let mut primaries: Vec<PathBuf> = Vec::new();
859        for dir in dirs {
860            if !dir.exists() {
861                continue;
862            }
863            let primary = detect_checkout(dir).primary;
864            if !primaries.contains(&primary) {
865                primaries.push(primary);
866            }
867        }
868
869        let mut registry = Self::default();
870        let mut colliding: Vec<String> = Vec::new();
871        for primary in primaries {
872            let Some(label) = project_label(&primary) else {
873                continue;
874            };
875
876            if let Some(existing) = registry.projects.get(&label) {
877                if existing.primary.dir != primary {
878                    let registered = registered_project_label(&existing.primary.dir).is_some()
879                        || registered_project_label(&primary).is_some();
880                    registry.errors.push(project_label_collision_message(
881                        &label,
882                        &existing.primary.dir,
883                        &primary,
884                        registered,
885                    ));
886                    // Neither is routed: answering for one of them would serve
887                    // the wrong project's daemons under a URL the other
888                    // checkout advertises for itself.
889                    colliding.push(label);
890                }
891                continue;
892            }
893
894            let worktrees = worktree_dirs(&primary);
895            let Some((project, errors)) = build_project_hosts(&primary, &label, &worktrees) else {
896                continue;
897            };
898            registry.errors.extend(errors);
899            // A directory with no routable daemon anywhere in it is not a
900            // project worth a hostname; an ad-hoc daemon's working directory
901            // would otherwise become an empty one.
902            if project.checkouts().all(|c| c.daemons.is_empty()) {
903                continue;
904            }
905            registry.projects.insert(label, project);
906        }
907        for label in colliding {
908            registry.projects.remove(&label);
909        }
910
911        registry
912    }
913
914    /// Whether more than one checkout across all known projects would run this
915    /// daemon ID.
916    ///
917    /// A namespace comes from the checkout's own directory name or config, not
918    /// from the project above it, so two unrelated projects that each have a
919    /// `fix-1` worktree with an `api` daemon both produce `fix-1/api`. The
920    /// state file holds one record per ID, so in that case a request has to be
921    /// matched to the checkout it names, exactly as for two checkouts of one
922    /// project.
923    pub fn shares_daemon_id(&self, namespace: &str, daemon: &str) -> bool {
924        self.projects
925            .values()
926            .flat_map(ProjectHosts::checkouts)
927            .filter(|c| c.namespace == namespace && c.daemons.values().any(|d| d.name == daemon))
928            .count()
929            > 1
930    }
931
932    /// Sorted project labels.
933    pub fn project_labels(&self) -> Vec<String> {
934        let mut labels: Vec<String> = self.projects.keys().cloned().collect();
935        labels.sort();
936        labels
937    }
938
939    /// Resolve a hostname's labels (the host with the TLD already stripped).
940    ///
941    /// Resolution runs right to left: the last label must name a project, an
942    /// optional worktree label follows, and the label before the daemon's is
943    /// where extra leading labels become wildcard subdomains of the same
944    /// daemon. A label that names both a worktree and a daemon is read as the
945    /// worktree.
946    pub fn resolve(&self, subdomain: &str, wildcard: bool) -> HostTarget {
947        let labels: Vec<String> = subdomain
948            .split('.')
949            .map(|l| l.to_ascii_lowercase())
950            .collect();
951        let Some((project_label, rest)) = labels.split_last() else {
952            return HostTarget::UnknownProject {
953                known: self.project_labels(),
954            };
955        };
956        let Some(project) = self.projects.get(project_label) else {
957            return HostTarget::UnknownProject {
958                known: self.project_labels(),
959            };
960        };
961        if rest.is_empty() {
962            return HostTarget::ProjectPage {
963                project: project.label.clone(),
964            };
965        }
966
967        // A worktree label directly left of the project consumes one label.
968        let (checkout, worktree, rest) = match rest.split_last() {
969            Some((maybe_worktree, head)) => match project.worktrees.get(maybe_worktree) {
970                Some(checkout) => (checkout, Some(maybe_worktree.clone()), head),
971                None => (&project.primary, None, rest),
972            },
973            None => (&project.primary, None, rest),
974        };
975
976        let Some((daemon_label, extra)) = rest.split_last() else {
977            return HostTarget::WorktreePage {
978                project: project.label.clone(),
979                worktree: worktree.unwrap_or_default(),
980            };
981        };
982        if !extra.is_empty() && !wildcard {
983            return HostTarget::UnknownDaemon {
984                project: project.label.clone(),
985                worktree,
986                known: checkout.labels(),
987            };
988        }
989
990        match checkout.daemons.get(daemon_label) {
991            Some(daemon) => HostTarget::Daemon {
992                project: project.label.clone(),
993                worktree,
994                dir: checkout.dir.clone(),
995                namespace: checkout.namespace.clone(),
996                daemon: daemon.name.clone(),
997                proxy_tls: daemon.proxy_tls,
998                proxy_tls_port: daemon.proxy_tls_port,
999            },
1000            None => HostTarget::UnknownDaemon {
1001                project: project.label.clone(),
1002                worktree,
1003                known: checkout.labels(),
1004            },
1005        }
1006    }
1007}
1008
1009#[cfg(test)]
1010mod tests {
1011    use super::*;
1012
1013    /// The path as `detect_checkout` reports it: canonical, and without the
1014    /// `\\?\` verbatim prefix Windows canonicalization adds.
1015    fn canonical(path: &Path) -> PathBuf {
1016        dunce::simplified(&path.canonicalize().unwrap()).to_path_buf()
1017    }
1018
1019    fn checkout(dir: &str, namespace: &str, daemons: &[(&str, &str)]) -> CheckoutHosts {
1020        CheckoutHosts {
1021            dir: PathBuf::from(dir),
1022            namespace: namespace.to_string(),
1023            daemons: daemons
1024                .iter()
1025                .map(|(l, n)| {
1026                    (
1027                        l.to_string(),
1028                        DaemonHost {
1029                            name: n.to_string(),
1030                            proxy_tls: None,
1031                            proxy_tls_port: None,
1032                        },
1033                    )
1034                })
1035                .collect(),
1036        }
1037    }
1038
1039    #[test]
1040    fn test_sanitize_label() {
1041        assert_eq!(sanitize_label("api").as_deref(), Some("api"));
1042        assert_eq!(sanitize_label("My App").as_deref(), Some("my-app"));
1043        assert_eq!(
1044            sanitize_label("feature/my_branch").as_deref(),
1045            Some("feature-my-branch")
1046        );
1047        assert_eq!(sanitize_label("--weird--").as_deref(), Some("weird"));
1048        assert_eq!(sanitize_label("café").as_deref(), Some("caf"));
1049        assert_eq!(sanitize_label("---"), None);
1050        assert_eq!(sanitize_label(""), None);
1051        assert_eq!(sanitize_label(&"a".repeat(80)).unwrap().len(), 63);
1052    }
1053
1054    #[test]
1055    fn test_daemon_label_override_and_opt_out() {
1056        assert_eq!(daemon_label("api", None).as_deref(), Some("api"));
1057        assert_eq!(
1058            daemon_label("api", Some(&ProxyConfig::Enabled)).as_deref(),
1059            Some("api")
1060        );
1061        assert_eq!(
1062            daemon_label("api", Some(&ProxyConfig::Name("Web UI".into()))).as_deref(),
1063            Some("web-ui")
1064        );
1065        assert_eq!(daemon_label("api", Some(&ProxyConfig::Disabled)), None);
1066    }
1067
1068    /// A primary checkout has a `.git` directory and no worktree label.
1069    #[test]
1070    fn test_detect_checkout_primary() {
1071        let temp = tempfile::tempdir().unwrap();
1072        let repo = temp.path().join("my-repo");
1073        std::fs::create_dir_all(repo.join(".git")).unwrap();
1074        std::fs::create_dir_all(repo.join("sub/dir")).unwrap();
1075
1076        let found = detect_checkout(&repo.join("sub/dir"));
1077        assert_eq!(found.primary, canonical(&repo));
1078        assert_eq!(found.worktree, None);
1079    }
1080
1081    /// A linked worktree's `.git` file points into `<common>/worktrees/<name>`.
1082    #[test]
1083    fn test_detect_checkout_linked_worktree() {
1084        let temp = tempfile::tempdir().unwrap();
1085        let repo = temp.path().join("my-repo");
1086        std::fs::create_dir_all(repo.join(".git/worktrees/fix-1")).unwrap();
1087        let wt = temp.path().join("fix-1");
1088        std::fs::create_dir_all(&wt).unwrap();
1089        std::fs::write(
1090            wt.join(".git"),
1091            format!("gitdir: {}\n", repo.join(".git/worktrees/fix-1").display()),
1092        )
1093        .unwrap();
1094
1095        let found = detect_checkout(&wt);
1096        assert_eq!(found.primary, canonical(&repo));
1097        assert_eq!(found.worktree, Some(canonical(&wt)));
1098        assert_eq!(found.root(), canonical(&wt));
1099    }
1100
1101    /// A working directory that no longer exists still belongs to the checkout
1102    /// above it, so a daemon whose directory was deleted keeps its hostname.
1103    #[test]
1104    fn test_checkout_root_of_missing_directory() {
1105        let temp = tempfile::tempdir().unwrap();
1106        let repo = temp.path().join("my-repo");
1107        std::fs::create_dir_all(repo.join(".git")).unwrap();
1108
1109        assert_eq!(
1110            checkout_root_of(&repo.join("gone/deeper")),
1111            canonical(&repo)
1112        );
1113    }
1114
1115    /// The primary checkout read from a worktree's `gitdir:` pointer is spelled
1116    /// the same as the one found by walking into the primary directly, whatever
1117    /// alias the pointer took.
1118    #[cfg(unix)]
1119    #[test]
1120    fn test_detect_checkout_primary_spelling_matches_through_symlink() {
1121        use std::os::unix::fs::symlink;
1122
1123        let temp = tempfile::tempdir().unwrap();
1124        let repo = temp.path().join("my-repo");
1125        let admin = repo.join(".git/worktrees/fix-1");
1126        std::fs::create_dir_all(&admin).unwrap();
1127        let wt = temp.path().join("fix-1");
1128        std::fs::create_dir_all(&wt).unwrap();
1129
1130        // Point the worktree at the repository through a symlinked alias, the
1131        // way a checkout under a symlinked home directory would.
1132        let alias = temp.path().join("alias");
1133        symlink(&repo, &alias).unwrap();
1134        std::fs::write(
1135            wt.join(".git"),
1136            format!("gitdir: {}\n", alias.join(".git/worktrees/fix-1").display()),
1137        )
1138        .unwrap();
1139
1140        assert_eq!(detect_checkout(&wt).primary, detect_checkout(&repo).primary);
1141        assert_eq!(detect_checkout(&wt).primary, canonical(&repo));
1142    }
1143
1144    /// A bare repository has no working tree to be the project, so its linked
1145    /// worktrees stand alone rather than being grouped under the directory that
1146    /// happens to hold the bare repositories.
1147    #[test]
1148    fn test_detect_checkout_bare_repository_worktree() {
1149        let temp = tempfile::tempdir().unwrap();
1150        let bare = temp.path().join("my-repo.git");
1151        let admin = bare.join("worktrees/fix-1");
1152        std::fs::create_dir_all(&admin).unwrap();
1153        let wt = temp.path().join("fix-1");
1154        std::fs::create_dir_all(&wt).unwrap();
1155        std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
1156
1157        let found = detect_checkout(&wt);
1158        assert_eq!(found.primary, canonical(&wt));
1159        assert_eq!(found.worktree, None);
1160    }
1161
1162    /// A daemon declared in a global config has no project, so it gets no
1163    /// automatic hostname; the config directory is not one.
1164    #[test]
1165    fn test_auto_host_skips_global_config_daemons() {
1166        let config = PitchforkTomlDaemon {
1167            run: "server".into(),
1168            port: Some(crate::config_types::PortConfig {
1169                expect: vec![3000],
1170                ..Default::default()
1171            }),
1172            path: Some(crate::env::PITCHFORK_GLOBAL_CONFIG_USER.clone()),
1173            ..PitchforkTomlDaemon::default()
1174        };
1175        let id = DaemonId::try_new("global", "api").unwrap();
1176        assert_eq!(auto_host_for_daemon(&id, &config), None);
1177    }
1178
1179    /// A checkout's label belongs to the daemon of that name in the checkout's
1180    /// own namespace, not to a same-named daemon of a project nested in it.
1181    #[test]
1182    fn test_routes_label_to_matches_namespace_and_name() {
1183        let hosts = checkout("/repo", "repo", &[("api", "api"), ("www", "web")]);
1184        let id = |ns: &str, name: &str| DaemonId::try_new(ns, name).unwrap();
1185        assert!(routes_label_to(&hosts, "api", &id("repo", "api")));
1186        assert!(routes_label_to(&hosts, "www", &id("repo", "web")));
1187        assert!(!routes_label_to(&hosts, "api", &id("frontend", "api")));
1188        assert!(!routes_label_to(&hosts, "web", &id("repo", "web")));
1189        assert!(!routes_label_to(&hosts, "api", &id("repo", "web")));
1190    }
1191
1192    /// A registered slug wins over the automatic hostname, because the proxy
1193    /// resolves slugs first.
1194    #[test]
1195    fn test_host_for_daemon_prefers_a_registered_slug() {
1196        let temp = tempfile::tempdir().unwrap();
1197        let repo = temp.path().join("slug-repo");
1198        std::fs::create_dir_all(&repo).unwrap();
1199        write_config(&repo, &[("api", "")]);
1200        let (id, config) = daemon_config(&repo, "api");
1201
1202        let empty = indexmap::IndexMap::new();
1203        assert_eq!(
1204            host_for_daemon(&id, Some(&config), &empty).as_deref(),
1205            Some("api.slug-repo")
1206        );
1207
1208        // A slug registered for the same daemon replaces it, dots and all.
1209        let mut slugs = indexmap::IndexMap::new();
1210        slugs.insert(
1211            "myapp".to_string(),
1212            crate::pitchfork_toml::SlugEntry {
1213                dir: Some(repo.clone()),
1214                namespace: Some(id.namespace().to_string()),
1215                daemon: Some("api".to_string()),
1216            },
1217        );
1218        assert_eq!(
1219            host_for_daemon(&id, Some(&config), &slugs).as_deref(),
1220            Some("myapp")
1221        );
1222    }
1223
1224    /// A `.git` file that is not a worktree pointer (a submodule) is its own
1225    /// checkout rather than a worktree of something else.
1226    #[test]
1227    fn test_detect_checkout_submodule_pointer() {
1228        let temp = tempfile::tempdir().unwrap();
1229        let repo = temp.path().join("my-repo");
1230        std::fs::create_dir_all(repo.join(".git/modules/sub")).unwrap();
1231        let sub = repo.join("sub");
1232        std::fs::create_dir_all(&sub).unwrap();
1233        std::fs::write(sub.join(".git"), "gitdir: ../.git/modules/sub\n").unwrap();
1234
1235        let found = detect_checkout(&sub);
1236        assert_eq!(found.primary, canonical(&sub));
1237        assert_eq!(found.worktree, None);
1238    }
1239
1240    /// Without a `.git` anywhere above it, a directory is its own project.
1241    #[test]
1242    fn test_detect_checkout_without_git() {
1243        let temp = tempfile::tempdir().unwrap();
1244        let dir = temp.path().join("plain");
1245        std::fs::create_dir_all(&dir).unwrap();
1246
1247        let found = detect_checkout(&dir);
1248        assert_eq!(found.primary, canonical(&dir));
1249        assert_eq!(found.worktree, None);
1250    }
1251
1252    fn registry() -> HostRegistry {
1253        let mut projects = HashMap::new();
1254        let mut worktrees = HashMap::new();
1255        worktrees.insert(
1256            "fix-1".to_string(),
1257            checkout("/repos/fix-1", "fix-1", &[("api", "api"), ("web", "web")]),
1258        );
1259        projects.insert(
1260            "myproj".to_string(),
1261            ProjectHosts {
1262                label: "myproj".to_string(),
1263                primary: checkout("/repos/myproj", "myproj", &[("api", "api")]),
1264                worktrees,
1265            },
1266        );
1267        HostRegistry {
1268            projects,
1269            errors: vec![],
1270        }
1271    }
1272
1273    /// The registry captures each daemon's TLS settings while it reads the
1274    /// checkout's config, so routing never has to read it again and cannot
1275    /// disagree with the hostname it built.
1276    #[test]
1277    fn test_checkout_hosts_capture_proxy_tls() {
1278        let dir = tempfile::tempdir().unwrap();
1279        let project = dir.path().join("tlsproj");
1280        std::fs::create_dir_all(&project).unwrap();
1281        std::fs::write(
1282            project.join("pitchfork.toml"),
1283            "[daemons.secure]\nrun = \"serve\"\nport = [8443, 9443]\n\
1284             proxy_tls = \"passthrough\"\nproxy_tls_port = 9443\n\
1285             [daemons.plain]\nrun = \"serve\"\nport = 8080\n",
1286        )
1287        .unwrap();
1288
1289        let (hosts, errors) = CheckoutHosts::load(&project).expect("checkout loads");
1290        assert!(errors.is_empty(), "{errors:?}");
1291
1292        let secure = hosts.daemons.get("secure").expect("secure is routable");
1293        assert_eq!(secure.name, "secure");
1294        assert_eq!(
1295            secure.proxy_tls,
1296            Some(crate::config_types::ProxyTlsMode::Passthrough)
1297        );
1298        assert_eq!(secure.proxy_tls_port, Some(9443));
1299
1300        let plain = hosts.daemons.get("plain").expect("plain is routable");
1301        assert_eq!(plain.proxy_tls, None);
1302        assert_eq!(plain.proxy_tls_port, None);
1303    }
1304
1305    #[test]
1306    fn test_resolve_primary_checkout_daemon() {
1307        let target = registry().resolve("api.myproj", true);
1308        assert_eq!(
1309            target,
1310            HostTarget::Daemon {
1311                project: "myproj".into(),
1312                worktree: None,
1313                dir: PathBuf::from("/repos/myproj"),
1314                namespace: "myproj".into(),
1315                daemon: "api".into(),
1316                proxy_tls: None,
1317                proxy_tls_port: None,
1318            }
1319        );
1320    }
1321
1322    #[test]
1323    fn test_resolve_worktree_daemon() {
1324        let target = registry().resolve("web.fix-1.myproj", true);
1325        assert_eq!(
1326            target,
1327            HostTarget::Daemon {
1328                project: "myproj".into(),
1329                worktree: Some("fix-1".into()),
1330                dir: PathBuf::from("/repos/fix-1"),
1331                namespace: "fix-1".into(),
1332                daemon: "web".into(),
1333                proxy_tls: None,
1334                proxy_tls_port: None,
1335            }
1336        );
1337    }
1338
1339    #[test]
1340    fn test_resolve_is_case_insensitive() {
1341        assert!(matches!(
1342            registry().resolve("API.MyProj", true),
1343            HostTarget::Daemon { .. }
1344        ));
1345    }
1346
1347    /// Project and stack pages are reserved: they never resolve to a daemon.
1348    #[test]
1349    fn test_resolve_reserved_pages() {
1350        assert_eq!(
1351            registry().resolve("myproj", true),
1352            HostTarget::ProjectPage {
1353                project: "myproj".into()
1354            }
1355        );
1356        assert_eq!(
1357            registry().resolve("fix-1.myproj", true),
1358            HostTarget::WorktreePage {
1359                project: "myproj".into(),
1360                worktree: "fix-1".into(),
1361            }
1362        );
1363    }
1364
1365    /// When a worktree label equals a daemon name, the worktree wins: the
1366    /// worktree is consumed first, so `api.myproj` is that stack's page and
1367    /// the primary checkout's `api` daemon is unreachable under that spelling.
1368    #[test]
1369    fn test_resolve_worktree_beats_daemon_of_same_name() {
1370        let mut reg = registry();
1371        // A worktree whose label is also a daemon name of the primary checkout.
1372        reg.projects.get_mut("myproj").unwrap().worktrees.insert(
1373            "api".to_string(),
1374            checkout("/repos/api-wt", "api-wt", &[("api", "api")]),
1375        );
1376        assert_eq!(
1377            reg.resolve("api.myproj", true),
1378            HostTarget::WorktreePage {
1379                project: "myproj".into(),
1380                worktree: "api".into(),
1381            }
1382        );
1383        // The daemon inside that worktree is still reachable.
1384        assert!(matches!(
1385            reg.resolve("api.api.myproj", true),
1386            HostTarget::Daemon { .. }
1387        ));
1388    }
1389
1390    #[test]
1391    fn test_resolve_wildcard_subdomain() {
1392        let target = registry().resolve("tenant.api.myproj", true);
1393        assert!(matches!(target, HostTarget::Daemon { daemon, .. } if daemon == "api"));
1394        // Wildcards off: the extra label is not a daemon of its own.
1395        assert!(matches!(
1396            registry().resolve("tenant.api.myproj", false),
1397            HostTarget::UnknownDaemon { .. }
1398        ));
1399    }
1400
1401    #[test]
1402    fn test_resolve_unknown_names() {
1403        assert_eq!(
1404            registry().resolve("api.other", true),
1405            HostTarget::UnknownProject {
1406                known: vec!["myproj".to_string()]
1407            }
1408        );
1409        assert_eq!(
1410            registry().resolve("nope.myproj", true),
1411            HostTarget::UnknownDaemon {
1412                project: "myproj".into(),
1413                worktree: None,
1414                known: vec!["api".to_string()],
1415            }
1416        );
1417    }
1418
1419    /// Build a primary checkout and a linked worktree of it on disk, with the
1420    /// same pair of pointers real `git worktree add` writes.
1421    fn git_project(temp: &Path, name: &str, worktree: &str) -> (PathBuf, PathBuf) {
1422        let repo = temp.join(name);
1423        let admin = repo.join(format!(".git/worktrees/{worktree}"));
1424        std::fs::create_dir_all(&admin).unwrap();
1425        let wt = temp.join(worktree);
1426        std::fs::create_dir_all(&wt).unwrap();
1427        std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
1428        std::fs::write(
1429            admin.join("gitdir"),
1430            format!("{}\n", wt.join(".git").display()),
1431        )
1432        .unwrap();
1433        (repo, wt)
1434    }
1435
1436    /// Without an explicit namespace the project label is the primary
1437    /// checkout's directory name, sanitized.
1438    #[test]
1439    fn test_project_label_from_directory_name() {
1440        let temp = tempfile::tempdir().unwrap();
1441        let repo = temp.path().join("My App");
1442        std::fs::create_dir_all(&repo).unwrap();
1443        assert_eq!(project_label(&repo).as_deref(), Some("my-app"));
1444    }
1445
1446    /// Registry label, then explicit namespace, then the directory name.
1447    #[test]
1448    fn test_pick_project_label_precedence() {
1449        let repo = Path::new("/work/Shop Repo");
1450        let ns = Some("shop-528f92b13a6784f0");
1451        assert_eq!(
1452            pick_project_label(repo, Some("shop"), ns).as_deref(),
1453            Some("shop")
1454        );
1455        assert_eq!(
1456            pick_project_label(repo, None, ns).as_deref(),
1457            Some("shop-528f92b13a6784f0")
1458        );
1459        assert_eq!(
1460            pick_project_label(repo, None, None).as_deref(),
1461            Some("shop-repo")
1462        );
1463        // An unusable registered label falls through instead of hiding the project.
1464        assert_eq!(
1465            pick_project_label(repo, Some("---"), ns).as_deref(),
1466            Some("shop-528f92b13a6784f0")
1467        );
1468    }
1469
1470    #[test]
1471    fn test_validate_registered_label() {
1472        assert!(validate_registered_label("shop").is_ok());
1473        assert!(validate_registered_label("my-shop-2").is_ok());
1474        assert!(validate_registered_label(&"a".repeat(63)).is_ok());
1475        for bad in [
1476            "Shop", "my_shop", "-shop", "shop-", "a--b", "a.b", "", "---",
1477        ] {
1478            assert!(validate_registered_label(bad).is_err(), "{bad:?}");
1479        }
1480        let long = validate_registered_label(&"a".repeat(64)).unwrap_err();
1481        assert!(long.contains("63"), "{long}");
1482        let hint = validate_registered_label("My Shop").unwrap_err();
1483        assert!(hint.contains("'my-shop'"), "{hint}");
1484    }
1485
1486    /// A project that declares a namespace uses that name instead.
1487    #[test]
1488    fn test_project_label_from_explicit_namespace() {
1489        let temp = tempfile::tempdir().unwrap();
1490        let repo = temp.path().join("checkout-dir");
1491        std::fs::create_dir_all(&repo).unwrap();
1492        std::fs::write(repo.join("pitchfork.toml"), "namespace = \"storefront\"\n").unwrap();
1493        assert_eq!(project_label(&repo).as_deref(), Some("storefront"));
1494    }
1495
1496    /// Write a `pitchfork.toml` with one daemon per entry, each with a port.
1497    fn write_config(dir: &Path, daemons: &[(&str, &str)]) {
1498        let body: String = daemons
1499            .iter()
1500            .enumerate()
1501            .map(|(i, (name, extra))| {
1502                format!(
1503                    "[daemons.{name}]\nrun = \"server\"\nport = {}\n{extra}\n",
1504                    3000 + i
1505                )
1506            })
1507            .collect();
1508        std::fs::write(dir.join("pitchfork.toml"), body).unwrap();
1509    }
1510
1511    /// The daemon config as the merged config holds it, anchored at `dir`.
1512    fn daemon_config(dir: &Path, name: &str) -> (DaemonId, PitchforkTomlDaemon) {
1513        let pt = PitchforkToml::all_merged_from(dir).unwrap();
1514        let (id, config) = pt
1515            .daemons
1516            .iter()
1517            .find(|(id, _)| id.name() == name)
1518            .unwrap_or_else(|| panic!("no daemon '{name}' in {}", dir.display()));
1519        (id.clone(), config.clone())
1520    }
1521
1522    /// A daemon in the primary checkout omits the worktree label; the same
1523    /// daemon in a linked worktree carries it.
1524    #[test]
1525    fn test_auto_host_primary_and_worktree() {
1526        let temp = tempfile::tempdir().unwrap();
1527        let (repo, wt) = git_project(temp.path(), "my-repo", "fix-1");
1528        write_config(&repo, &[("api", "")]);
1529        write_config(&wt, &[("api", "")]);
1530
1531        let (id, config) = daemon_config(&repo, "api");
1532        assert_eq!(
1533            auto_host_for_daemon(&id, &config).as_deref(),
1534            Some("api.my-repo")
1535        );
1536
1537        let (wt_id, wt_config) = daemon_config(&wt, "api");
1538        assert_eq!(
1539            auto_host_for_daemon(&wt_id, &wt_config).as_deref(),
1540            Some("api.fix-1.my-repo")
1541        );
1542    }
1543
1544    /// `worktree_label` in the worktree's own config replaces its directory name.
1545    #[test]
1546    fn test_worktree_label_override() {
1547        let temp = tempfile::tempdir().unwrap();
1548        let (_repo, wt) = git_project(temp.path(), "my-repo", "sleepy-kapitsa-9f02fc");
1549        assert_eq!(
1550            worktree_label(&wt).as_deref(),
1551            Some("sleepy-kapitsa-9f02fc")
1552        );
1553
1554        std::fs::write(wt.join("pitchfork.toml"), "worktree_label = \"Fix 1\"\n").unwrap();
1555        assert_eq!(worktree_label(&wt).as_deref(), Some("fix-1"));
1556    }
1557
1558    /// A daemon opts out with `proxy = false` and gets no hostname; one without
1559    /// a port never had one to begin with; a string renames its label.
1560    #[test]
1561    fn test_auto_host_requires_port_and_opt_in() {
1562        let temp = tempfile::tempdir().unwrap();
1563        let repo = temp.path().join("opt-in-repo");
1564        std::fs::create_dir_all(&repo).unwrap();
1565        std::fs::write(
1566            repo.join("pitchfork.toml"),
1567            "[daemons.api]\nrun = \"server\"\nport = 3000\n\n\
1568             [daemons.admin]\nrun = \"server\"\nport = 3001\nproxy = false\n\n\
1569             [daemons.web-frontend]\nrun = \"server\"\nport = 3002\nproxy = \"web\"\n\n\
1570             [daemons.worker]\nrun = \"server\"\n",
1571        )
1572        .unwrap();
1573
1574        let host = |name: &str| {
1575            let (id, config) = daemon_config(&repo, name);
1576            auto_host_for_daemon(&id, &config)
1577        };
1578        assert_eq!(host("api").as_deref(), Some("api.opt-in-repo"));
1579        assert_eq!(host("admin"), None);
1580        assert_eq!(host("web-frontend").as_deref(), Some("web.opt-in-repo"));
1581        assert_eq!(host("worker"), None);
1582    }
1583
1584    /// Two daemons whose labels reduce to the same string are both dropped,
1585    /// and neither is advertised as a URL.
1586    #[test]
1587    fn test_daemon_label_collision_drops_both() {
1588        let temp = tempfile::tempdir().unwrap();
1589        let repo = temp.path().join("dupe-repo");
1590        std::fs::create_dir_all(&repo).unwrap();
1591        std::fs::write(
1592            repo.join("pitchfork.toml"),
1593            "[daemons.foo_bar]\nrun = \"server\"\nport = 3000\n\n\
1594             [daemons.foo-bar]\nrun = \"server\"\nport = 3001\n\n\
1595             [daemons.other]\nrun = \"server\"\nport = 3002\n",
1596        )
1597        .unwrap();
1598
1599        let (hosts, errors) = CheckoutHosts::load(&repo).unwrap();
1600        assert_eq!(hosts.labels(), vec!["other".to_string()]);
1601        assert_eq!(errors.len(), 1);
1602        assert!(errors[0].contains("foo-bar"), "{}", errors[0]);
1603
1604        for name in ["foo_bar", "foo-bar"] {
1605            let (id, config) = daemon_config(&repo, name);
1606            assert_eq!(auto_host_for_daemon(&id, &config), None, "{name}");
1607        }
1608        let (id, config) = daemon_config(&repo, "other");
1609        assert_eq!(
1610            auto_host_for_daemon(&id, &config).as_deref(),
1611            Some("other.dupe-repo")
1612        );
1613    }
1614
1615    /// A name that cannot fit alongside the configured TLD is refused by every
1616    /// surface, so nothing advertises an address DNS would reject.
1617    #[test]
1618    fn test_hostname_fits() {
1619        // The default TLD leaves room for three maximum-length labels.
1620        assert!(hostname_fits(&format!(
1621            "{}.{}.{}",
1622            "a".repeat(63),
1623            "b".repeat(63),
1624            "c".repeat(63)
1625        )));
1626        assert!(!hostname_fits(&"a".repeat(MAX_HOSTNAME_LEN)));
1627    }
1628
1629    /// Two worktrees whose labels collide are dropped from the project, and
1630    /// neither advertises a URL the proxy would refuse to route.
1631    #[test]
1632    fn test_auto_host_none_for_colliding_worktrees() {
1633        let temp = tempfile::tempdir().unwrap();
1634        let repo = temp.path().join("wt-repo");
1635        std::fs::create_dir_all(repo.join(".git")).unwrap();
1636        write_config(&repo, &[("api", "")]);
1637
1638        let mut worktrees = Vec::new();
1639        for dir_name in ["fix-1", "fix.1"] {
1640            let admin = repo.join(format!(".git/worktrees/{dir_name}"));
1641            std::fs::create_dir_all(&admin).unwrap();
1642            let wt = temp.path().join(dir_name);
1643            std::fs::create_dir_all(&wt).unwrap();
1644            std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
1645            std::fs::write(
1646                admin.join("gitdir"),
1647                format!("{}\n", wt.join(".git").display()),
1648            )
1649            .unwrap();
1650            write_config(&wt, &[("api", "")]);
1651            worktrees.push(wt);
1652        }
1653
1654        for wt in &worktrees {
1655            let (id, config) = daemon_config(wt, "api");
1656            assert_eq!(
1657                auto_host_for_daemon(&id, &config),
1658                None,
1659                "{} must not advertise a hostname",
1660                wt.display()
1661            );
1662        }
1663        // The primary checkout is unaffected by its worktrees' collision.
1664        let (id, config) = daemon_config(&repo, "api");
1665        assert_eq!(
1666            auto_host_for_daemon(&id, &config).as_deref(),
1667            Some("api.wt-repo")
1668        );
1669    }
1670
1671    /// Checkouts that share a namespace cannot be told apart by daemon ID,
1672    /// which the proxy has to know before trusting a state record. Namespaces
1673    /// come from directory names, so the clash can span unrelated projects.
1674    #[test]
1675    fn test_shares_daemon_id() {
1676        let project = |label: &str, primary: CheckoutHosts, wts: Vec<(&str, CheckoutHosts)>| {
1677            (
1678                label.to_string(),
1679                ProjectHosts {
1680                    label: label.to_string(),
1681                    primary,
1682                    worktrees: wts.into_iter().map(|(l, c)| (l.to_string(), c)).collect(),
1683                },
1684            )
1685        };
1686
1687        // One project, one worktree, each with its own namespace.
1688        let mut registry = HostRegistry {
1689            projects: HashMap::from([project(
1690                "myproj",
1691                checkout("/repos/myproj", "myproj", &[("api", "api")]),
1692                vec![(
1693                    "fix-1",
1694                    checkout("/repos/fix-1", "fix-1", &[("api", "api")]),
1695                )],
1696            )]),
1697            errors: vec![],
1698        };
1699        assert!(!registry.shares_daemon_id("myproj", "api"));
1700        assert!(!registry.shares_daemon_id("fix-1", "api"));
1701
1702        // A worktree inheriting the project's explicit namespace clashes with
1703        // the primary checkout.
1704        registry
1705            .projects
1706            .get_mut("myproj")
1707            .unwrap()
1708            .worktrees
1709            .insert(
1710                "fix-2".to_string(),
1711                checkout("/repos/fix-2", "myproj", &[("api", "api")]),
1712            );
1713        assert!(registry.shares_daemon_id("myproj", "api"));
1714
1715        // So does an identically named worktree of an unrelated project, whose
1716        // namespace is its directory name.
1717        let (label, other) = project(
1718            "other",
1719            checkout("/repos/other", "other", &[("api", "api")]),
1720            vec![(
1721                "fix-1",
1722                checkout("/repos/other/fix-1", "fix-1", &[("api", "api")]),
1723            )],
1724        );
1725        registry.projects.insert(label, other);
1726        assert!(registry.shares_daemon_id("fix-1", "api"));
1727
1728        // A daemon only one checkout defines stays unambiguous.
1729        assert!(!registry.shares_daemon_id("myproj", "worker"));
1730    }
1731
1732    /// Two worktrees reducing to one label are both dropped, and the error
1733    /// names both directories.
1734    #[test]
1735    fn test_worktree_label_collision_drops_both() {
1736        let found = vec![
1737            (
1738                "fix-1".to_string(),
1739                checkout("/repos/fix-1", "fix-1", &[("api", "api")]),
1740            ),
1741            (
1742                "fix-1".to_string(),
1743                checkout("/repos/fix.1", "fix-1b", &[("api", "api")]),
1744            ),
1745            (
1746                "fix-2".to_string(),
1747                checkout("/repos/fix-2", "fix-2", &[("api", "api")]),
1748            ),
1749        ];
1750        let (kept, errors) = group_worktrees("myproj", found);
1751        assert_eq!(kept.keys().collect::<Vec<_>>(), vec!["fix-2"]);
1752        assert_eq!(errors.len(), 1);
1753        assert!(errors[0].contains("/repos/fix-1"), "{}", errors[0]);
1754        assert!(errors[0].contains("/repos/fix.1"), "{}", errors[0]);
1755    }
1756
1757    /// A label clash between registered labels is fixed by registering another
1758    /// label, not by a namespace that the registered label outranks.
1759    #[test]
1760    fn test_project_label_collision_message_depends_on_source() {
1761        let a = Path::new("/repos/a");
1762        let b = Path::new("/repos/b");
1763        let plain = project_label_collision_message("shop", a, b, false);
1764        assert!(plain.contains("`namespace`"), "{plain}");
1765        assert!(!plain.contains("--label"), "{plain}");
1766        let registered = project_label_collision_message("shop", a, b, true);
1767        assert!(
1768            registered.contains("pitchfork config add <file> --label <other>"),
1769            "{registered}"
1770        );
1771        assert!(!registered.contains("`namespace`"), "{registered}");
1772        for msg in [&plain, &registered] {
1773            assert!(msg.contains("'shop'") && msg.contains("/repos/a") && msg.contains("/repos/b"));
1774        }
1775    }
1776
1777    /// Two projects that reduce to one label are reported, and neither is
1778    /// routed: answering for one would serve the wrong project's daemons.
1779    #[test]
1780    fn test_project_label_collision_routes_neither() {
1781        let temp = tempfile::tempdir().unwrap();
1782        let a = temp.path().join("a/shop");
1783        let b = temp.path().join("b/shop");
1784        std::fs::create_dir_all(&a).unwrap();
1785        std::fs::create_dir_all(&b).unwrap();
1786        write_config(&a, &[("api", "")]);
1787        write_config(&b, &[("api", "")]);
1788
1789        let registry = HostRegistry::from_dirs(&[a.clone(), b.clone()]);
1790        assert!(registry.project_labels().is_empty());
1791        assert_eq!(registry.errors.len(), 1);
1792        assert!(
1793            registry.errors[0].contains("shop"),
1794            "{}",
1795            registry.errors[0]
1796        );
1797        // The error names the checkout as the registry resolved it, which is
1798        // not always how the test spelled the path.
1799        assert!(
1800            registry.errors[0].contains(&canonical(&b).display().to_string()),
1801            "{}",
1802            registry.errors[0]
1803        );
1804        assert_eq!(
1805            registry.resolve("api.shop", true),
1806            HostTarget::UnknownProject { known: vec![] }
1807        );
1808    }
1809}