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_protocol` as the checkout's config had it, `None` when unset.
452    pub proxy_protocol: Option<crate::config_types::ProxyProtocol>,
453    /// `proxy_tls_port`, or its shorter `proxy_port` spelling.
454    pub proxy_tls_port: Option<u16>,
455}
456
457impl CheckoutHosts {
458    /// Load the routable daemons of one checkout, along with any collisions
459    /// found while doing so.
460    ///
461    /// Two daemons whose labels reduce to the same string are both dropped:
462    /// keeping either one would route half the requests to a daemon the user
463    /// did not name, and which of the two won would depend on config order.
464    fn load(dir: &Path) -> Option<(Self, Vec<String>)> {
465        let namespace = PitchforkToml::namespace_for_dir(dir).ok()?;
466        let pt = PitchforkToml::all_merged_from(dir).ok()?;
467        let mut daemons: HashMap<String, DaemonHost> = HashMap::new();
468        let mut errors = Vec::new();
469        let mut colliding: Vec<String> = Vec::new();
470        for (id, config) in &pt.daemons {
471            if id.namespace() != namespace || config.port.is_none() {
472                continue;
473            }
474            let Some(label) = daemon_label(id.name(), config.proxy.as_ref()) else {
475                continue;
476            };
477            match daemons.get(&label) {
478                Some(other) => {
479                    errors.push(format!(
480                        "daemon hostname label '{label}' in {} is claimed by both '{other}' and \
481                         '{name}'. Rename one of them, or set `proxy = \"<label>\"` on one.",
482                        dir.display(),
483                        other = other.name,
484                        name = id.name(),
485                    ));
486                    colliding.push(label);
487                }
488                None => {
489                    daemons.insert(
490                        label,
491                        DaemonHost {
492                            name: id.name().to_string(),
493                            proxy_tls: config.proxy_tls,
494                            proxy_protocol: config.proxy_protocol,
495                            proxy_tls_port: config.effective_proxy_tls_port(),
496                        },
497                    );
498                }
499            }
500        }
501        for label in colliding {
502            daemons.remove(&label);
503        }
504        Some((
505            Self {
506                dir: dir.to_path_buf(),
507                namespace,
508                daemons,
509            },
510            errors,
511        ))
512    }
513
514    /// Sorted daemon labels, for listings and error pages.
515    pub fn labels(&self) -> Vec<String> {
516        let mut labels: Vec<String> = self.daemons.keys().cloned().collect();
517        labels.sort();
518        labels
519    }
520}
521
522/// A project and its checkouts, keyed by hostname label.
523#[derive(Debug, Clone)]
524pub struct ProjectHosts {
525    pub label: String,
526    pub primary: CheckoutHosts,
527    /// Worktree label → checkout.
528    pub worktrees: HashMap<String, CheckoutHosts>,
529}
530
531impl ProjectHosts {
532    /// Every checkout of this project: its primary and each linked worktree.
533    pub fn checkouts(&self) -> impl Iterator<Item = &CheckoutHosts> {
534        std::iter::once(&self.primary).chain(self.worktrees.values())
535    }
536
537    /// Sorted worktree labels.
538    pub fn worktree_labels(&self) -> Vec<String> {
539        let mut labels: Vec<String> = self.worktrees.keys().cloned().collect();
540        labels.sort();
541        labels
542    }
543}
544
545/// All projects the proxy can route to, keyed by project label.
546#[derive(Debug, Clone, Default)]
547pub struct HostRegistry {
548    pub projects: HashMap<String, ProjectHosts>,
549    /// Label collisions found while loading, reported to the user as-is.
550    pub errors: Vec<String>,
551}
552
553/// What a hostname resolves to.
554#[derive(Debug, Clone, PartialEq, Eq)]
555pub enum HostTarget {
556    /// A daemon in one of the project's checkouts.
557    Daemon {
558        project: String,
559        worktree: Option<String>,
560        dir: PathBuf,
561        namespace: String,
562        daemon: String,
563        /// `proxy_tls` as the checkout's config had it when the registry was
564        /// built, so routing and the hostname come from one read.
565        proxy_tls: Option<crate::config_types::ProxyTlsMode>,
566        /// `proxy_protocol`, from the same read.
567        proxy_protocol: Option<crate::config_types::ProxyProtocol>,
568        /// `proxy_tls_port`, from the same read.
569        proxy_tls_port: Option<u16>,
570    },
571    /// `<project>.<tld>` — reserved for the project page.
572    ProjectPage { project: String },
573    /// `<worktree>.<project>.<tld>` — reserved for the stack page.
574    WorktreePage { project: String, worktree: String },
575    /// The rightmost label is not a known project.
576    UnknownProject { known: Vec<String> },
577    /// The project is known but the daemon label is not.
578    UnknownDaemon {
579        project: String,
580        worktree: Option<String>,
581        known: Vec<String>,
582    },
583}
584
585/// Group a project's worktree checkouts by label.
586///
587/// Two worktrees that reduce to the same label are both dropped and reported:
588/// routing one of them would answer half the requests with the other's content,
589/// which is worse than not routing the label at all.
590fn group_worktrees(
591    project: &str,
592    found: Vec<(String, CheckoutHosts)>,
593) -> (HashMap<String, CheckoutHosts>, Vec<String>) {
594    let mut kept: HashMap<String, CheckoutHosts> = HashMap::new();
595    let mut errors = Vec::new();
596    let mut colliding: Vec<String> = Vec::new();
597    for (label, hosts) in found {
598        match kept.get(&label) {
599            Some(existing) if existing.dir == hosts.dir => continue,
600            Some(existing) => {
601                errors.push(format!(
602                    "worktree label '{label}' in project '{project}' is claimed by two \
603                     directories: {} and {}. Set `worktree_label` in one of them.",
604                    existing.dir.display(),
605                    hosts.dir.display(),
606                ));
607                colliding.push(label);
608            }
609            None => {
610                kept.insert(label, hosts);
611            }
612        }
613    }
614    for label in colliding {
615        kept.remove(&label);
616    }
617    (kept, errors)
618}
619
620/// How long a project's worktree discovery stays cached.
621///
622/// `pitchfork list` derives a hostname for every daemon it prints, and each one
623/// would otherwise re-enumerate the project's worktrees. Only the enumeration
624/// is cached: configuration is re-read every time, so a label that starts
625/// colliding stops being advertised at once, and only a brand-new worktree
626/// directory can take up to this long to appear.
627const WORKTREE_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(2);
628
629struct WorktreeCache {
630    entries: HashMap<PathBuf, (std::time::Instant, std::sync::Arc<Vec<PathBuf>>)>,
631}
632
633static WORKTREE_CACHE: once_cell::sync::Lazy<std::sync::Mutex<WorktreeCache>> =
634    once_cell::sync::Lazy::new(|| {
635        std::sync::Mutex::new(WorktreeCache {
636            entries: HashMap::new(),
637        })
638    });
639
640/// The linked worktree roots of a project, cached briefly.
641fn cached_worktree_dirs(primary: &Path) -> std::sync::Arc<Vec<PathBuf>> {
642    let now = std::time::Instant::now();
643    {
644        let cache = WORKTREE_CACHE.lock().unwrap_or_else(|e| e.into_inner());
645        if let Some((expires_at, dirs)) = cache.entries.get(primary)
646            && now < *expires_at
647        {
648            return std::sync::Arc::clone(dirs);
649        }
650    } // lock released before any I/O
651
652    let dirs = std::sync::Arc::new(worktree_dirs(primary));
653
654    let mut cache = WORKTREE_CACHE.lock().unwrap_or_else(|e| e.into_inner());
655    cache.entries.retain(|_, (expires_at, _)| now < *expires_at);
656    cache.entries.insert(
657        primary.to_path_buf(),
658        (now + WORKTREE_CACHE_TTL, std::sync::Arc::clone(&dirs)),
659    );
660    dirs
661}
662
663/// Report a configuration problem once per distinct message.
664///
665/// A label collision persists until someone renames something, while the
666/// registry behind it is rebuilt every couple of seconds and on every CLI
667/// invocation. Logging each rebuild would fill the supervisor log with the same
668/// line; the user needs to read it once.
669pub fn warn_once(message: &str) {
670    static SEEN: once_cell::sync::Lazy<std::sync::Mutex<std::collections::HashSet<String>>> =
671        once_cell::sync::Lazy::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
672
673    let mut seen = SEEN.lock().unwrap_or_else(|e| e.into_inner());
674    // Distinct messages are bounded by the configuration, but a pathological
675    // one should not grow the set forever.
676    if seen.len() > 256 {
677        seen.clear();
678    }
679    if seen.insert(message.to_string()) {
680        log::warn!("{message}");
681    }
682}
683
684/// Directories that may contain a project pitchfork knows about.
685///
686/// Only persisted knowledge counts: the namespace registry, the legacy slug
687/// registry, and the directories of daemons in the state file. The current
688/// directory is deliberately absent, because it differs between the supervisor
689/// and each CLI invocation, and a registry that depended on it would let the
690/// CLI advertise a hostname the proxy does not route — or refuse one it does.
691fn candidate_dirs() -> Vec<PathBuf> {
692    let mut candidates: Vec<PathBuf> = Vec::new();
693    for (_, entry) in PitchforkToml::read_global_namespaces() {
694        candidates.push(entry.dir);
695    }
696    for (_, entry) in PitchforkToml::read_global_slugs() {
697        if let Some(dir) = entry.resolve_dir() {
698            candidates.push(dir);
699        }
700    }
701    if let Ok(state) = crate::state_file::StateFile::read(&*crate::env::PITCHFORK_STATE_FILE) {
702        for daemon in state.daemons.values() {
703            if let Some(dir) = &daemon.dir {
704                candidates.push(dir.clone());
705            }
706        }
707    }
708    candidates
709}
710
711/// Whether another known project claims the same project label.
712///
713/// Such a label routes to neither project, so nothing may advertise a URL
714/// under it. This reads configuration files but never enumerates worktrees,
715/// which keeps it cheap enough for the per-daemon display paths.
716fn project_label_is_ambiguous(label: &str, primary: &Path) -> bool {
717    let mut seen: Vec<PathBuf> = Vec::new();
718    for dir in candidate_dirs() {
719        if !dir.exists() {
720            continue;
721        }
722        let other = detect_checkout(&dir).primary;
723        if other == primary || seen.contains(&other) {
724            continue;
725        }
726        seen.push(other.clone());
727        if project_label(&other).as_deref() == Some(label) {
728            return true;
729        }
730    }
731    false
732}
733
734/// The error for two projects that reduce to one label.
735///
736/// When either label came from a registration, a registered label outranks a
737/// namespace, so setting a `namespace` cannot resolve the clash; the advice is
738/// to register a different label instead.
739fn project_label_collision_message(
740    label: &str,
741    first: &Path,
742    second: &Path,
743    from_registration: bool,
744) -> String {
745    let advice = if from_registration {
746        "Register a different label for one of them with \
747         `pitchfork config add <file> --label <other>`."
748    } else {
749        "Set a distinct top-level `namespace` in one of them."
750    };
751    format!(
752        "project label '{label}' is claimed by two directories: {} and {}. {advice}",
753        first.display(),
754        second.display(),
755    )
756}
757
758/// Linked worktree roots recorded in a checkout's git common directory.
759///
760/// Each `<common>/worktrees/<name>/gitdir` holds the path of that worktree's
761/// own `.git` file, whose parent is the worktree root. Reading them directly
762/// costs no subprocess and still works when `git` is unavailable, so it
763/// complements the `git worktree list` discovery that also covers jj.
764fn linked_worktree_dirs(primary: &Path) -> Vec<PathBuf> {
765    let Ok(entries) = std::fs::read_dir(primary.join(".git/worktrees")) else {
766        return vec![];
767    };
768    entries
769        .filter_map(|entry| {
770            let gitdir = std::fs::read_to_string(entry.ok()?.path().join("gitdir")).ok()?;
771            let git_file = PathBuf::from(gitdir.trim());
772            git_file.parent().map(Path::to_path_buf)
773        })
774        .collect()
775}
776
777/// Every linked worktree root of a project, from both discovery sources.
778///
779/// `git worktree list` also covers jj workspaces, and the `gitdir` pointers
780/// cover repositories where `git` is unavailable or errors.
781fn worktree_dirs(primary: &Path) -> Vec<PathBuf> {
782    if !crate::settings::settings().general.worktree {
783        return vec![];
784    }
785    let mut dirs: Vec<PathBuf> = Vec::new();
786    let found = crate::proxy::worktree::discover_worktrees(primary)
787        .into_iter()
788        .map(|entry| entry.path)
789        .chain(linked_worktree_dirs(primary));
790    for path in found {
791        let Some(wt_dir) = detect_checkout(&path).worktree else {
792            continue; // the primary checkout itself
793        };
794        if !dirs.contains(&wt_dir) {
795            dirs.push(wt_dir);
796        }
797    }
798    dirs
799}
800
801/// Build one project's checkouts: its primary and every linked worktree.
802///
803/// Returns the project together with the label collisions found inside it.
804/// Configuration is read fresh on every call, so a collision introduced by an
805/// edit takes effect immediately; only `worktree_dirs` may be cached.
806fn build_project_hosts(
807    primary: &Path,
808    label: &str,
809    worktrees: &[PathBuf],
810) -> Option<(ProjectHosts, Vec<String>)> {
811    let (primary_hosts, mut errors) = CheckoutHosts::load(primary)?;
812    let mut project = ProjectHosts {
813        label: label.to_string(),
814        primary: primary_hosts,
815        worktrees: HashMap::new(),
816    };
817
818    let mut found: Vec<(String, CheckoutHosts)> = Vec::new();
819    for wt_dir in worktrees {
820        let (Some(wt_label), Some((hosts, wt_errors))) =
821            (worktree_label(wt_dir), CheckoutHosts::load(wt_dir))
822        else {
823            continue;
824        };
825        errors.extend(wt_errors);
826        found.push((wt_label, hosts));
827    }
828    let (worktrees, wt_errors) = group_worktrees(label, found);
829    project.worktrees = worktrees;
830    errors.extend(wt_errors);
831
832    Some((project, errors))
833}
834
835/// Build a project the way the proxy routes it, reusing a brief cache of its
836/// worktree enumeration.
837fn project_hosts_for(primary: &Path, label: &str) -> Option<ProjectHosts> {
838    let worktrees = cached_worktree_dirs(primary);
839    let (project, errors) = build_project_hosts(primary, label, &worktrees)?;
840    for err in errors {
841        warn_once(&err);
842    }
843    Some(project)
844}
845
846impl HostRegistry {
847    /// Build the registry from every project pitchfork knows about.
848    ///
849    /// Candidate directories come from the current directory, the namespace
850    /// registry, the legacy slug registry, and the directories of daemons in
851    /// the state file. Each is mapped to its primary checkout, whose linked
852    /// worktrees are then discovered.
853    ///
854    /// A project the supervisor has never seen — never started, never
855    /// registered — is therefore not routable from another directory yet.
856    pub fn build() -> Self {
857        Self::from_dirs(&candidate_dirs())
858    }
859
860    /// Build the registry from an explicit list of project directories.
861    pub fn from_dirs(dirs: &[PathBuf]) -> Self {
862        // Collapse the candidates to distinct primary checkouts.
863        let mut primaries: Vec<PathBuf> = Vec::new();
864        for dir in dirs {
865            if !dir.exists() {
866                continue;
867            }
868            let primary = detect_checkout(dir).primary;
869            if !primaries.contains(&primary) {
870                primaries.push(primary);
871            }
872        }
873
874        let mut registry = Self::default();
875        let mut colliding: Vec<String> = Vec::new();
876        for primary in primaries {
877            let Some(label) = project_label(&primary) else {
878                continue;
879            };
880
881            if let Some(existing) = registry.projects.get(&label) {
882                if existing.primary.dir != primary {
883                    let registered = registered_project_label(&existing.primary.dir).is_some()
884                        || registered_project_label(&primary).is_some();
885                    registry.errors.push(project_label_collision_message(
886                        &label,
887                        &existing.primary.dir,
888                        &primary,
889                        registered,
890                    ));
891                    // Neither is routed: answering for one of them would serve
892                    // the wrong project's daemons under a URL the other
893                    // checkout advertises for itself.
894                    colliding.push(label);
895                }
896                continue;
897            }
898
899            let worktrees = worktree_dirs(&primary);
900            let Some((project, errors)) = build_project_hosts(&primary, &label, &worktrees) else {
901                continue;
902            };
903            registry.errors.extend(errors);
904            // A directory with no routable daemon anywhere in it is not a
905            // project worth a hostname; an ad-hoc daemon's working directory
906            // would otherwise become an empty one.
907            if project.checkouts().all(|c| c.daemons.is_empty()) {
908                continue;
909            }
910            registry.projects.insert(label, project);
911        }
912        for label in colliding {
913            registry.projects.remove(&label);
914        }
915
916        registry
917    }
918
919    /// Whether more than one checkout across all known projects would run this
920    /// daemon ID.
921    ///
922    /// A namespace comes from the checkout's own directory name or config, not
923    /// from the project above it, so two unrelated projects that each have a
924    /// `fix-1` worktree with an `api` daemon both produce `fix-1/api`. The
925    /// state file holds one record per ID, so in that case a request has to be
926    /// matched to the checkout it names, exactly as for two checkouts of one
927    /// project.
928    pub fn shares_daemon_id(&self, namespace: &str, daemon: &str) -> bool {
929        self.projects
930            .values()
931            .flat_map(ProjectHosts::checkouts)
932            .filter(|c| c.namespace == namespace && c.daemons.values().any(|d| d.name == daemon))
933            .count()
934            > 1
935    }
936
937    /// Sorted project labels.
938    pub fn project_labels(&self) -> Vec<String> {
939        let mut labels: Vec<String> = self.projects.keys().cloned().collect();
940        labels.sort();
941        labels
942    }
943
944    /// Resolve a hostname's labels (the host with the TLD already stripped).
945    ///
946    /// Resolution runs right to left: the last label must name a project, an
947    /// optional worktree label follows, and the label before the daemon's is
948    /// where extra leading labels become wildcard subdomains of the same
949    /// daemon. A label that names both a worktree and a daemon is read as the
950    /// worktree.
951    pub fn resolve(&self, subdomain: &str, wildcard: bool) -> HostTarget {
952        let labels: Vec<String> = subdomain
953            .split('.')
954            .map(|l| l.to_ascii_lowercase())
955            .collect();
956        let Some((project_label, rest)) = labels.split_last() else {
957            return HostTarget::UnknownProject {
958                known: self.project_labels(),
959            };
960        };
961        let Some(project) = self.projects.get(project_label) else {
962            return HostTarget::UnknownProject {
963                known: self.project_labels(),
964            };
965        };
966        if rest.is_empty() {
967            return HostTarget::ProjectPage {
968                project: project.label.clone(),
969            };
970        }
971
972        // A worktree label directly left of the project consumes one label.
973        let (checkout, worktree, rest) = match rest.split_last() {
974            Some((maybe_worktree, head)) => match project.worktrees.get(maybe_worktree) {
975                Some(checkout) => (checkout, Some(maybe_worktree.clone()), head),
976                None => (&project.primary, None, rest),
977            },
978            None => (&project.primary, None, rest),
979        };
980
981        let Some((daemon_label, extra)) = rest.split_last() else {
982            return HostTarget::WorktreePage {
983                project: project.label.clone(),
984                worktree: worktree.unwrap_or_default(),
985            };
986        };
987        if !extra.is_empty() && !wildcard {
988            return HostTarget::UnknownDaemon {
989                project: project.label.clone(),
990                worktree,
991                known: checkout.labels(),
992            };
993        }
994
995        match checkout.daemons.get(daemon_label) {
996            Some(daemon) => HostTarget::Daemon {
997                project: project.label.clone(),
998                worktree,
999                dir: checkout.dir.clone(),
1000                namespace: checkout.namespace.clone(),
1001                daemon: daemon.name.clone(),
1002                proxy_tls: daemon.proxy_tls,
1003                proxy_protocol: daemon.proxy_protocol,
1004                proxy_tls_port: daemon.proxy_tls_port,
1005            },
1006            None => HostTarget::UnknownDaemon {
1007                project: project.label.clone(),
1008                worktree,
1009                known: checkout.labels(),
1010            },
1011        }
1012    }
1013}
1014
1015#[cfg(test)]
1016mod tests {
1017    use super::*;
1018
1019    /// The path as `detect_checkout` reports it: canonical, and without the
1020    /// `\\?\` verbatim prefix Windows canonicalization adds.
1021    fn canonical(path: &Path) -> PathBuf {
1022        dunce::simplified(&path.canonicalize().unwrap()).to_path_buf()
1023    }
1024
1025    fn checkout(dir: &str, namespace: &str, daemons: &[(&str, &str)]) -> CheckoutHosts {
1026        CheckoutHosts {
1027            dir: PathBuf::from(dir),
1028            namespace: namespace.to_string(),
1029            daemons: daemons
1030                .iter()
1031                .map(|(l, n)| {
1032                    (
1033                        l.to_string(),
1034                        DaemonHost {
1035                            name: n.to_string(),
1036                            proxy_tls: None,
1037                            proxy_protocol: None,
1038                            proxy_tls_port: None,
1039                        },
1040                    )
1041                })
1042                .collect(),
1043        }
1044    }
1045
1046    #[test]
1047    fn test_sanitize_label() {
1048        assert_eq!(sanitize_label("api").as_deref(), Some("api"));
1049        assert_eq!(sanitize_label("My App").as_deref(), Some("my-app"));
1050        assert_eq!(
1051            sanitize_label("feature/my_branch").as_deref(),
1052            Some("feature-my-branch")
1053        );
1054        assert_eq!(sanitize_label("--weird--").as_deref(), Some("weird"));
1055        assert_eq!(sanitize_label("café").as_deref(), Some("caf"));
1056        assert_eq!(sanitize_label("---"), None);
1057        assert_eq!(sanitize_label(""), None);
1058        assert_eq!(sanitize_label(&"a".repeat(80)).unwrap().len(), 63);
1059    }
1060
1061    #[test]
1062    fn test_daemon_label_override_and_opt_out() {
1063        assert_eq!(daemon_label("api", None).as_deref(), Some("api"));
1064        assert_eq!(
1065            daemon_label("api", Some(&ProxyConfig::Enabled)).as_deref(),
1066            Some("api")
1067        );
1068        assert_eq!(
1069            daemon_label("api", Some(&ProxyConfig::Name("Web UI".into()))).as_deref(),
1070            Some("web-ui")
1071        );
1072        assert_eq!(daemon_label("api", Some(&ProxyConfig::Disabled)), None);
1073    }
1074
1075    /// A primary checkout has a `.git` directory and no worktree label.
1076    #[test]
1077    fn test_detect_checkout_primary() {
1078        let temp = tempfile::tempdir().unwrap();
1079        let repo = temp.path().join("my-repo");
1080        std::fs::create_dir_all(repo.join(".git")).unwrap();
1081        std::fs::create_dir_all(repo.join("sub/dir")).unwrap();
1082
1083        let found = detect_checkout(&repo.join("sub/dir"));
1084        assert_eq!(found.primary, canonical(&repo));
1085        assert_eq!(found.worktree, None);
1086    }
1087
1088    /// A linked worktree's `.git` file points into `<common>/worktrees/<name>`.
1089    #[test]
1090    fn test_detect_checkout_linked_worktree() {
1091        let temp = tempfile::tempdir().unwrap();
1092        let repo = temp.path().join("my-repo");
1093        std::fs::create_dir_all(repo.join(".git/worktrees/fix-1")).unwrap();
1094        let wt = temp.path().join("fix-1");
1095        std::fs::create_dir_all(&wt).unwrap();
1096        std::fs::write(
1097            wt.join(".git"),
1098            format!("gitdir: {}\n", repo.join(".git/worktrees/fix-1").display()),
1099        )
1100        .unwrap();
1101
1102        let found = detect_checkout(&wt);
1103        assert_eq!(found.primary, canonical(&repo));
1104        assert_eq!(found.worktree, Some(canonical(&wt)));
1105        assert_eq!(found.root(), canonical(&wt));
1106    }
1107
1108    /// A working directory that no longer exists still belongs to the checkout
1109    /// above it, so a daemon whose directory was deleted keeps its hostname.
1110    #[test]
1111    fn test_checkout_root_of_missing_directory() {
1112        let temp = tempfile::tempdir().unwrap();
1113        let repo = temp.path().join("my-repo");
1114        std::fs::create_dir_all(repo.join(".git")).unwrap();
1115
1116        assert_eq!(
1117            checkout_root_of(&repo.join("gone/deeper")),
1118            canonical(&repo)
1119        );
1120    }
1121
1122    /// The primary checkout read from a worktree's `gitdir:` pointer is spelled
1123    /// the same as the one found by walking into the primary directly, whatever
1124    /// alias the pointer took.
1125    #[cfg(unix)]
1126    #[test]
1127    fn test_detect_checkout_primary_spelling_matches_through_symlink() {
1128        use std::os::unix::fs::symlink;
1129
1130        let temp = tempfile::tempdir().unwrap();
1131        let repo = temp.path().join("my-repo");
1132        let admin = repo.join(".git/worktrees/fix-1");
1133        std::fs::create_dir_all(&admin).unwrap();
1134        let wt = temp.path().join("fix-1");
1135        std::fs::create_dir_all(&wt).unwrap();
1136
1137        // Point the worktree at the repository through a symlinked alias, the
1138        // way a checkout under a symlinked home directory would.
1139        let alias = temp.path().join("alias");
1140        symlink(&repo, &alias).unwrap();
1141        std::fs::write(
1142            wt.join(".git"),
1143            format!("gitdir: {}\n", alias.join(".git/worktrees/fix-1").display()),
1144        )
1145        .unwrap();
1146
1147        assert_eq!(detect_checkout(&wt).primary, detect_checkout(&repo).primary);
1148        assert_eq!(detect_checkout(&wt).primary, canonical(&repo));
1149    }
1150
1151    /// A bare repository has no working tree to be the project, so its linked
1152    /// worktrees stand alone rather than being grouped under the directory that
1153    /// happens to hold the bare repositories.
1154    #[test]
1155    fn test_detect_checkout_bare_repository_worktree() {
1156        let temp = tempfile::tempdir().unwrap();
1157        let bare = temp.path().join("my-repo.git");
1158        let admin = bare.join("worktrees/fix-1");
1159        std::fs::create_dir_all(&admin).unwrap();
1160        let wt = temp.path().join("fix-1");
1161        std::fs::create_dir_all(&wt).unwrap();
1162        std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
1163
1164        let found = detect_checkout(&wt);
1165        assert_eq!(found.primary, canonical(&wt));
1166        assert_eq!(found.worktree, None);
1167    }
1168
1169    /// A daemon declared in a global config has no project, so it gets no
1170    /// automatic hostname; the config directory is not one.
1171    #[test]
1172    fn test_auto_host_skips_global_config_daemons() {
1173        let config = PitchforkTomlDaemon {
1174            run: "server".into(),
1175            port: Some(crate::config_types::PortConfig {
1176                expect: vec![3000],
1177                ..Default::default()
1178            }),
1179            path: Some(crate::env::PITCHFORK_GLOBAL_CONFIG_USER.clone()),
1180            ..PitchforkTomlDaemon::default()
1181        };
1182        let id = DaemonId::try_new("global", "api").unwrap();
1183        assert_eq!(auto_host_for_daemon(&id, &config), None);
1184    }
1185
1186    /// A checkout's label belongs to the daemon of that name in the checkout's
1187    /// own namespace, not to a same-named daemon of a project nested in it.
1188    #[test]
1189    fn test_routes_label_to_matches_namespace_and_name() {
1190        let hosts = checkout("/repo", "repo", &[("api", "api"), ("www", "web")]);
1191        let id = |ns: &str, name: &str| DaemonId::try_new(ns, name).unwrap();
1192        assert!(routes_label_to(&hosts, "api", &id("repo", "api")));
1193        assert!(routes_label_to(&hosts, "www", &id("repo", "web")));
1194        assert!(!routes_label_to(&hosts, "api", &id("frontend", "api")));
1195        assert!(!routes_label_to(&hosts, "web", &id("repo", "web")));
1196        assert!(!routes_label_to(&hosts, "api", &id("repo", "web")));
1197    }
1198
1199    /// A registered slug wins over the automatic hostname, because the proxy
1200    /// resolves slugs first.
1201    #[test]
1202    fn test_host_for_daemon_prefers_a_registered_slug() {
1203        let temp = tempfile::tempdir().unwrap();
1204        let repo = temp.path().join("slug-repo");
1205        std::fs::create_dir_all(&repo).unwrap();
1206        write_config(&repo, &[("api", "")]);
1207        let (id, config) = daemon_config(&repo, "api");
1208
1209        let empty = indexmap::IndexMap::new();
1210        assert_eq!(
1211            host_for_daemon(&id, Some(&config), &empty).as_deref(),
1212            Some("api.slug-repo")
1213        );
1214
1215        // A slug registered for the same daemon replaces it, dots and all.
1216        let mut slugs = indexmap::IndexMap::new();
1217        slugs.insert(
1218            "myapp".to_string(),
1219            crate::pitchfork_toml::SlugEntry {
1220                dir: Some(repo.clone()),
1221                namespace: Some(id.namespace().to_string()),
1222                daemon: Some("api".to_string()),
1223            },
1224        );
1225        assert_eq!(
1226            host_for_daemon(&id, Some(&config), &slugs).as_deref(),
1227            Some("myapp")
1228        );
1229    }
1230
1231    /// A `.git` file that is not a worktree pointer (a submodule) is its own
1232    /// checkout rather than a worktree of something else.
1233    #[test]
1234    fn test_detect_checkout_submodule_pointer() {
1235        let temp = tempfile::tempdir().unwrap();
1236        let repo = temp.path().join("my-repo");
1237        std::fs::create_dir_all(repo.join(".git/modules/sub")).unwrap();
1238        let sub = repo.join("sub");
1239        std::fs::create_dir_all(&sub).unwrap();
1240        std::fs::write(sub.join(".git"), "gitdir: ../.git/modules/sub\n").unwrap();
1241
1242        let found = detect_checkout(&sub);
1243        assert_eq!(found.primary, canonical(&sub));
1244        assert_eq!(found.worktree, None);
1245    }
1246
1247    /// Without a `.git` anywhere above it, a directory is its own project.
1248    #[test]
1249    fn test_detect_checkout_without_git() {
1250        let temp = tempfile::tempdir().unwrap();
1251        let dir = temp.path().join("plain");
1252        std::fs::create_dir_all(&dir).unwrap();
1253
1254        let found = detect_checkout(&dir);
1255        assert_eq!(found.primary, canonical(&dir));
1256        assert_eq!(found.worktree, None);
1257    }
1258
1259    fn registry() -> HostRegistry {
1260        let mut projects = HashMap::new();
1261        let mut worktrees = HashMap::new();
1262        worktrees.insert(
1263            "fix-1".to_string(),
1264            checkout("/repos/fix-1", "fix-1", &[("api", "api"), ("web", "web")]),
1265        );
1266        projects.insert(
1267            "myproj".to_string(),
1268            ProjectHosts {
1269                label: "myproj".to_string(),
1270                primary: checkout("/repos/myproj", "myproj", &[("api", "api")]),
1271                worktrees,
1272            },
1273        );
1274        HostRegistry {
1275            projects,
1276            errors: vec![],
1277        }
1278    }
1279
1280    /// The registry captures each daemon's TLS settings while it reads the
1281    /// checkout's config, so routing never has to read it again and cannot
1282    /// disagree with the hostname it built.
1283    #[test]
1284    fn test_checkout_hosts_capture_proxy_tls() {
1285        let dir = tempfile::tempdir().unwrap();
1286        let project = dir.path().join("tlsproj");
1287        std::fs::create_dir_all(&project).unwrap();
1288        std::fs::write(
1289            project.join("pitchfork.toml"),
1290            "[daemons.secure]\nrun = \"serve\"\nport = [8443, 9443]\n\
1291             proxy_tls = \"passthrough\"\nproxy_tls_port = 9443\n\
1292             [daemons.plain]\nrun = \"serve\"\nport = 8080\n",
1293        )
1294        .unwrap();
1295
1296        let (hosts, errors) = CheckoutHosts::load(&project).expect("checkout loads");
1297        assert!(errors.is_empty(), "{errors:?}");
1298
1299        let secure = hosts.daemons.get("secure").expect("secure is routable");
1300        assert_eq!(secure.name, "secure");
1301        assert_eq!(
1302            secure.proxy_tls,
1303            Some(crate::config_types::ProxyTlsMode::Passthrough)
1304        );
1305        assert_eq!(secure.proxy_tls_port, Some(9443));
1306
1307        let plain = hosts.daemons.get("plain").expect("plain is routable");
1308        assert_eq!(plain.proxy_tls, None);
1309        assert_eq!(plain.proxy_tls_port, None);
1310    }
1311
1312    #[test]
1313    fn test_resolve_primary_checkout_daemon() {
1314        let target = registry().resolve("api.myproj", true);
1315        assert_eq!(
1316            target,
1317            HostTarget::Daemon {
1318                project: "myproj".into(),
1319                worktree: None,
1320                dir: PathBuf::from("/repos/myproj"),
1321                namespace: "myproj".into(),
1322                daemon: "api".into(),
1323                proxy_tls: None,
1324                proxy_protocol: None,
1325                proxy_tls_port: None,
1326            }
1327        );
1328    }
1329
1330    #[test]
1331    fn test_resolve_worktree_daemon() {
1332        let target = registry().resolve("web.fix-1.myproj", true);
1333        assert_eq!(
1334            target,
1335            HostTarget::Daemon {
1336                project: "myproj".into(),
1337                worktree: Some("fix-1".into()),
1338                dir: PathBuf::from("/repos/fix-1"),
1339                namespace: "fix-1".into(),
1340                daemon: "web".into(),
1341                proxy_tls: None,
1342                proxy_protocol: None,
1343                proxy_tls_port: None,
1344            }
1345        );
1346    }
1347
1348    #[test]
1349    fn test_resolve_is_case_insensitive() {
1350        assert!(matches!(
1351            registry().resolve("API.MyProj", true),
1352            HostTarget::Daemon { .. }
1353        ));
1354    }
1355
1356    /// Project and stack pages are reserved: they never resolve to a daemon.
1357    #[test]
1358    fn test_resolve_reserved_pages() {
1359        assert_eq!(
1360            registry().resolve("myproj", true),
1361            HostTarget::ProjectPage {
1362                project: "myproj".into()
1363            }
1364        );
1365        assert_eq!(
1366            registry().resolve("fix-1.myproj", true),
1367            HostTarget::WorktreePage {
1368                project: "myproj".into(),
1369                worktree: "fix-1".into(),
1370            }
1371        );
1372    }
1373
1374    /// When a worktree label equals a daemon name, the worktree wins: the
1375    /// worktree is consumed first, so `api.myproj` is that stack's page and
1376    /// the primary checkout's `api` daemon is unreachable under that spelling.
1377    #[test]
1378    fn test_resolve_worktree_beats_daemon_of_same_name() {
1379        let mut reg = registry();
1380        // A worktree whose label is also a daemon name of the primary checkout.
1381        reg.projects.get_mut("myproj").unwrap().worktrees.insert(
1382            "api".to_string(),
1383            checkout("/repos/api-wt", "api-wt", &[("api", "api")]),
1384        );
1385        assert_eq!(
1386            reg.resolve("api.myproj", true),
1387            HostTarget::WorktreePage {
1388                project: "myproj".into(),
1389                worktree: "api".into(),
1390            }
1391        );
1392        // The daemon inside that worktree is still reachable.
1393        assert!(matches!(
1394            reg.resolve("api.api.myproj", true),
1395            HostTarget::Daemon { .. }
1396        ));
1397    }
1398
1399    #[test]
1400    fn test_resolve_wildcard_subdomain() {
1401        let target = registry().resolve("tenant.api.myproj", true);
1402        assert!(matches!(target, HostTarget::Daemon { daemon, .. } if daemon == "api"));
1403        // Wildcards off: the extra label is not a daemon of its own.
1404        assert!(matches!(
1405            registry().resolve("tenant.api.myproj", false),
1406            HostTarget::UnknownDaemon { .. }
1407        ));
1408    }
1409
1410    #[test]
1411    fn test_resolve_unknown_names() {
1412        assert_eq!(
1413            registry().resolve("api.other", true),
1414            HostTarget::UnknownProject {
1415                known: vec!["myproj".to_string()]
1416            }
1417        );
1418        assert_eq!(
1419            registry().resolve("nope.myproj", true),
1420            HostTarget::UnknownDaemon {
1421                project: "myproj".into(),
1422                worktree: None,
1423                known: vec!["api".to_string()],
1424            }
1425        );
1426    }
1427
1428    /// Build a primary checkout and a linked worktree of it on disk, with the
1429    /// same pair of pointers real `git worktree add` writes.
1430    fn git_project(temp: &Path, name: &str, worktree: &str) -> (PathBuf, PathBuf) {
1431        let repo = temp.join(name);
1432        let admin = repo.join(format!(".git/worktrees/{worktree}"));
1433        std::fs::create_dir_all(&admin).unwrap();
1434        let wt = temp.join(worktree);
1435        std::fs::create_dir_all(&wt).unwrap();
1436        std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
1437        std::fs::write(
1438            admin.join("gitdir"),
1439            format!("{}\n", wt.join(".git").display()),
1440        )
1441        .unwrap();
1442        (repo, wt)
1443    }
1444
1445    /// Without an explicit namespace the project label is the primary
1446    /// checkout's directory name, sanitized.
1447    #[test]
1448    fn test_project_label_from_directory_name() {
1449        let temp = tempfile::tempdir().unwrap();
1450        let repo = temp.path().join("My App");
1451        std::fs::create_dir_all(&repo).unwrap();
1452        assert_eq!(project_label(&repo).as_deref(), Some("my-app"));
1453    }
1454
1455    /// Registry label, then explicit namespace, then the directory name.
1456    #[test]
1457    fn test_pick_project_label_precedence() {
1458        let repo = Path::new("/work/Shop Repo");
1459        let ns = Some("shop-528f92b13a6784f0");
1460        assert_eq!(
1461            pick_project_label(repo, Some("shop"), ns).as_deref(),
1462            Some("shop")
1463        );
1464        assert_eq!(
1465            pick_project_label(repo, None, ns).as_deref(),
1466            Some("shop-528f92b13a6784f0")
1467        );
1468        assert_eq!(
1469            pick_project_label(repo, None, None).as_deref(),
1470            Some("shop-repo")
1471        );
1472        // An unusable registered label falls through instead of hiding the project.
1473        assert_eq!(
1474            pick_project_label(repo, Some("---"), ns).as_deref(),
1475            Some("shop-528f92b13a6784f0")
1476        );
1477    }
1478
1479    #[test]
1480    fn test_validate_registered_label() {
1481        assert!(validate_registered_label("shop").is_ok());
1482        assert!(validate_registered_label("my-shop-2").is_ok());
1483        assert!(validate_registered_label(&"a".repeat(63)).is_ok());
1484        for bad in [
1485            "Shop", "my_shop", "-shop", "shop-", "a--b", "a.b", "", "---",
1486        ] {
1487            assert!(validate_registered_label(bad).is_err(), "{bad:?}");
1488        }
1489        let long = validate_registered_label(&"a".repeat(64)).unwrap_err();
1490        assert!(long.contains("63"), "{long}");
1491        let hint = validate_registered_label("My Shop").unwrap_err();
1492        assert!(hint.contains("'my-shop'"), "{hint}");
1493    }
1494
1495    /// A project that declares a namespace uses that name instead.
1496    #[test]
1497    fn test_project_label_from_explicit_namespace() {
1498        let temp = tempfile::tempdir().unwrap();
1499        let repo = temp.path().join("checkout-dir");
1500        std::fs::create_dir_all(&repo).unwrap();
1501        std::fs::write(repo.join("pitchfork.toml"), "namespace = \"storefront\"\n").unwrap();
1502        assert_eq!(project_label(&repo).as_deref(), Some("storefront"));
1503    }
1504
1505    /// Write a `pitchfork.toml` with one daemon per entry, each with a port.
1506    fn write_config(dir: &Path, daemons: &[(&str, &str)]) {
1507        let body: String = daemons
1508            .iter()
1509            .enumerate()
1510            .map(|(i, (name, extra))| {
1511                format!(
1512                    "[daemons.{name}]\nrun = \"server\"\nport = {}\n{extra}\n",
1513                    3000 + i
1514                )
1515            })
1516            .collect();
1517        std::fs::write(dir.join("pitchfork.toml"), body).unwrap();
1518    }
1519
1520    /// The daemon config as the merged config holds it, anchored at `dir`.
1521    fn daemon_config(dir: &Path, name: &str) -> (DaemonId, PitchforkTomlDaemon) {
1522        let pt = PitchforkToml::all_merged_from(dir).unwrap();
1523        let (id, config) = pt
1524            .daemons
1525            .iter()
1526            .find(|(id, _)| id.name() == name)
1527            .unwrap_or_else(|| panic!("no daemon '{name}' in {}", dir.display()));
1528        (id.clone(), config.clone())
1529    }
1530
1531    /// A daemon in the primary checkout omits the worktree label; the same
1532    /// daemon in a linked worktree carries it.
1533    #[test]
1534    fn test_auto_host_primary_and_worktree() {
1535        let temp = tempfile::tempdir().unwrap();
1536        let (repo, wt) = git_project(temp.path(), "my-repo", "fix-1");
1537        write_config(&repo, &[("api", "")]);
1538        write_config(&wt, &[("api", "")]);
1539
1540        let (id, config) = daemon_config(&repo, "api");
1541        assert_eq!(
1542            auto_host_for_daemon(&id, &config).as_deref(),
1543            Some("api.my-repo")
1544        );
1545
1546        let (wt_id, wt_config) = daemon_config(&wt, "api");
1547        assert_eq!(
1548            auto_host_for_daemon(&wt_id, &wt_config).as_deref(),
1549            Some("api.fix-1.my-repo")
1550        );
1551    }
1552
1553    /// `worktree_label` in the worktree's own config replaces its directory name.
1554    #[test]
1555    fn test_worktree_label_override() {
1556        let temp = tempfile::tempdir().unwrap();
1557        let (_repo, wt) = git_project(temp.path(), "my-repo", "sleepy-kapitsa-9f02fc");
1558        assert_eq!(
1559            worktree_label(&wt).as_deref(),
1560            Some("sleepy-kapitsa-9f02fc")
1561        );
1562
1563        std::fs::write(wt.join("pitchfork.toml"), "worktree_label = \"Fix 1\"\n").unwrap();
1564        assert_eq!(worktree_label(&wt).as_deref(), Some("fix-1"));
1565    }
1566
1567    /// A daemon opts out with `proxy = false` and gets no hostname; one without
1568    /// a port never had one to begin with; a string renames its label.
1569    #[test]
1570    fn test_auto_host_requires_port_and_opt_in() {
1571        let temp = tempfile::tempdir().unwrap();
1572        let repo = temp.path().join("opt-in-repo");
1573        std::fs::create_dir_all(&repo).unwrap();
1574        std::fs::write(
1575            repo.join("pitchfork.toml"),
1576            "[daemons.api]\nrun = \"server\"\nport = 3000\n\n\
1577             [daemons.admin]\nrun = \"server\"\nport = 3001\nproxy = false\n\n\
1578             [daemons.web-frontend]\nrun = \"server\"\nport = 3002\nproxy = \"web\"\n\n\
1579             [daemons.worker]\nrun = \"server\"\n",
1580        )
1581        .unwrap();
1582
1583        let host = |name: &str| {
1584            let (id, config) = daemon_config(&repo, name);
1585            auto_host_for_daemon(&id, &config)
1586        };
1587        assert_eq!(host("api").as_deref(), Some("api.opt-in-repo"));
1588        assert_eq!(host("admin"), None);
1589        assert_eq!(host("web-frontend").as_deref(), Some("web.opt-in-repo"));
1590        assert_eq!(host("worker"), None);
1591    }
1592
1593    /// Two daemons whose labels reduce to the same string are both dropped,
1594    /// and neither is advertised as a URL.
1595    #[test]
1596    fn test_daemon_label_collision_drops_both() {
1597        let temp = tempfile::tempdir().unwrap();
1598        let repo = temp.path().join("dupe-repo");
1599        std::fs::create_dir_all(&repo).unwrap();
1600        std::fs::write(
1601            repo.join("pitchfork.toml"),
1602            "[daemons.foo_bar]\nrun = \"server\"\nport = 3000\n\n\
1603             [daemons.foo-bar]\nrun = \"server\"\nport = 3001\n\n\
1604             [daemons.other]\nrun = \"server\"\nport = 3002\n",
1605        )
1606        .unwrap();
1607
1608        let (hosts, errors) = CheckoutHosts::load(&repo).unwrap();
1609        assert_eq!(hosts.labels(), vec!["other".to_string()]);
1610        assert_eq!(errors.len(), 1);
1611        assert!(errors[0].contains("foo-bar"), "{}", errors[0]);
1612
1613        for name in ["foo_bar", "foo-bar"] {
1614            let (id, config) = daemon_config(&repo, name);
1615            assert_eq!(auto_host_for_daemon(&id, &config), None, "{name}");
1616        }
1617        let (id, config) = daemon_config(&repo, "other");
1618        assert_eq!(
1619            auto_host_for_daemon(&id, &config).as_deref(),
1620            Some("other.dupe-repo")
1621        );
1622    }
1623
1624    /// A name that cannot fit alongside the configured TLD is refused by every
1625    /// surface, so nothing advertises an address DNS would reject.
1626    #[test]
1627    fn test_hostname_fits() {
1628        // The default TLD leaves room for three maximum-length labels.
1629        assert!(hostname_fits(&format!(
1630            "{}.{}.{}",
1631            "a".repeat(63),
1632            "b".repeat(63),
1633            "c".repeat(63)
1634        )));
1635        assert!(!hostname_fits(&"a".repeat(MAX_HOSTNAME_LEN)));
1636    }
1637
1638    /// Two worktrees whose labels collide are dropped from the project, and
1639    /// neither advertises a URL the proxy would refuse to route.
1640    #[test]
1641    fn test_auto_host_none_for_colliding_worktrees() {
1642        let temp = tempfile::tempdir().unwrap();
1643        let repo = temp.path().join("wt-repo");
1644        std::fs::create_dir_all(repo.join(".git")).unwrap();
1645        write_config(&repo, &[("api", "")]);
1646
1647        let mut worktrees = Vec::new();
1648        for dir_name in ["fix-1", "fix.1"] {
1649            let admin = repo.join(format!(".git/worktrees/{dir_name}"));
1650            std::fs::create_dir_all(&admin).unwrap();
1651            let wt = temp.path().join(dir_name);
1652            std::fs::create_dir_all(&wt).unwrap();
1653            std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
1654            std::fs::write(
1655                admin.join("gitdir"),
1656                format!("{}\n", wt.join(".git").display()),
1657            )
1658            .unwrap();
1659            write_config(&wt, &[("api", "")]);
1660            worktrees.push(wt);
1661        }
1662
1663        for wt in &worktrees {
1664            let (id, config) = daemon_config(wt, "api");
1665            assert_eq!(
1666                auto_host_for_daemon(&id, &config),
1667                None,
1668                "{} must not advertise a hostname",
1669                wt.display()
1670            );
1671        }
1672        // The primary checkout is unaffected by its worktrees' collision.
1673        let (id, config) = daemon_config(&repo, "api");
1674        assert_eq!(
1675            auto_host_for_daemon(&id, &config).as_deref(),
1676            Some("api.wt-repo")
1677        );
1678    }
1679
1680    /// Checkouts that share a namespace cannot be told apart by daemon ID,
1681    /// which the proxy has to know before trusting a state record. Namespaces
1682    /// come from directory names, so the clash can span unrelated projects.
1683    #[test]
1684    fn test_shares_daemon_id() {
1685        let project = |label: &str, primary: CheckoutHosts, wts: Vec<(&str, CheckoutHosts)>| {
1686            (
1687                label.to_string(),
1688                ProjectHosts {
1689                    label: label.to_string(),
1690                    primary,
1691                    worktrees: wts.into_iter().map(|(l, c)| (l.to_string(), c)).collect(),
1692                },
1693            )
1694        };
1695
1696        // One project, one worktree, each with its own namespace.
1697        let mut registry = HostRegistry {
1698            projects: HashMap::from([project(
1699                "myproj",
1700                checkout("/repos/myproj", "myproj", &[("api", "api")]),
1701                vec![(
1702                    "fix-1",
1703                    checkout("/repos/fix-1", "fix-1", &[("api", "api")]),
1704                )],
1705            )]),
1706            errors: vec![],
1707        };
1708        assert!(!registry.shares_daemon_id("myproj", "api"));
1709        assert!(!registry.shares_daemon_id("fix-1", "api"));
1710
1711        // A worktree inheriting the project's explicit namespace clashes with
1712        // the primary checkout.
1713        registry
1714            .projects
1715            .get_mut("myproj")
1716            .unwrap()
1717            .worktrees
1718            .insert(
1719                "fix-2".to_string(),
1720                checkout("/repos/fix-2", "myproj", &[("api", "api")]),
1721            );
1722        assert!(registry.shares_daemon_id("myproj", "api"));
1723
1724        // So does an identically named worktree of an unrelated project, whose
1725        // namespace is its directory name.
1726        let (label, other) = project(
1727            "other",
1728            checkout("/repos/other", "other", &[("api", "api")]),
1729            vec![(
1730                "fix-1",
1731                checkout("/repos/other/fix-1", "fix-1", &[("api", "api")]),
1732            )],
1733        );
1734        registry.projects.insert(label, other);
1735        assert!(registry.shares_daemon_id("fix-1", "api"));
1736
1737        // A daemon only one checkout defines stays unambiguous.
1738        assert!(!registry.shares_daemon_id("myproj", "worker"));
1739    }
1740
1741    /// Two worktrees reducing to one label are both dropped, and the error
1742    /// names both directories.
1743    #[test]
1744    fn test_worktree_label_collision_drops_both() {
1745        let found = vec![
1746            (
1747                "fix-1".to_string(),
1748                checkout("/repos/fix-1", "fix-1", &[("api", "api")]),
1749            ),
1750            (
1751                "fix-1".to_string(),
1752                checkout("/repos/fix.1", "fix-1b", &[("api", "api")]),
1753            ),
1754            (
1755                "fix-2".to_string(),
1756                checkout("/repos/fix-2", "fix-2", &[("api", "api")]),
1757            ),
1758        ];
1759        let (kept, errors) = group_worktrees("myproj", found);
1760        assert_eq!(kept.keys().collect::<Vec<_>>(), vec!["fix-2"]);
1761        assert_eq!(errors.len(), 1);
1762        assert!(errors[0].contains("/repos/fix-1"), "{}", errors[0]);
1763        assert!(errors[0].contains("/repos/fix.1"), "{}", errors[0]);
1764    }
1765
1766    /// A label clash between registered labels is fixed by registering another
1767    /// label, not by a namespace that the registered label outranks.
1768    #[test]
1769    fn test_project_label_collision_message_depends_on_source() {
1770        let a = Path::new("/repos/a");
1771        let b = Path::new("/repos/b");
1772        let plain = project_label_collision_message("shop", a, b, false);
1773        assert!(plain.contains("`namespace`"), "{plain}");
1774        assert!(!plain.contains("--label"), "{plain}");
1775        let registered = project_label_collision_message("shop", a, b, true);
1776        assert!(
1777            registered.contains("pitchfork config add <file> --label <other>"),
1778            "{registered}"
1779        );
1780        assert!(!registered.contains("`namespace`"), "{registered}");
1781        for msg in [&plain, &registered] {
1782            assert!(msg.contains("'shop'") && msg.contains("/repos/a") && msg.contains("/repos/b"));
1783        }
1784    }
1785
1786    /// Two projects that reduce to one label are reported, and neither is
1787    /// routed: answering for one would serve the wrong project's daemons.
1788    #[test]
1789    fn test_project_label_collision_routes_neither() {
1790        let temp = tempfile::tempdir().unwrap();
1791        let a = temp.path().join("a/shop");
1792        let b = temp.path().join("b/shop");
1793        std::fs::create_dir_all(&a).unwrap();
1794        std::fs::create_dir_all(&b).unwrap();
1795        write_config(&a, &[("api", "")]);
1796        write_config(&b, &[("api", "")]);
1797
1798        let registry = HostRegistry::from_dirs(&[a.clone(), b.clone()]);
1799        assert!(registry.project_labels().is_empty());
1800        assert_eq!(registry.errors.len(), 1);
1801        assert!(
1802            registry.errors[0].contains("shop"),
1803            "{}",
1804            registry.errors[0]
1805        );
1806        // The error names the checkout as the registry resolved it, which is
1807        // not always how the test spelled the path.
1808        assert!(
1809            registry.errors[0].contains(&canonical(&b).display().to_string()),
1810            "{}",
1811            registry.errors[0]
1812        );
1813        assert_eq!(
1814            registry.resolve("api.shop", true),
1815            HostTarget::UnknownProject { known: vec![] }
1816        );
1817    }
1818}