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