Skip to main content

ryra_core/registry/
resolve.rs

1use std::path::{Path, PathBuf};
2
3use crate::config::schema::Config;
4use crate::error::{Error, Result};
5use crate::paths::{DEFAULT_REGISTRY_URL, REGISTRY_DIR_ENV};
6use crate::registry;
7
8/// A reference to a service in a registry.
9///
10/// - `Default("forgejo")` — refers to a service in the project-managed
11///   default registry (cloned from [`DEFAULT_REGISTRY_URL`]).
12/// - `Custom { registry: "acme", service: "forgejo" }` — refers to a
13///   service in a user-added custom registry.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum ServiceRef {
16    /// A service from the default registry. E.g., `forgejo`.
17    Default(String),
18    /// A service from a named custom registry. E.g., `acme/forgejo`.
19    Custom { registry: String, service: String },
20    /// A local project directory whose `service.toml` lives at its root
21    /// (`ryra add .` / `ryra add ./path`). `dir` is absolute; `name` is the
22    /// `[service].name` read from the file.
23    Path { dir: PathBuf, name: String },
24}
25
26/// Whether a CLI argument is a local project path rather than a registry
27/// reference. Purely *syntactic* — a path must carry an explicit marker (`.`,
28/// `..`, `./`, `../`, `/`, `~`), exactly like `./script` vs `script` in a shell.
29///
30/// Deliberately does NOT probe the filesystem: if a bare name like `forgejo`
31/// resolved to a local `./forgejo/` folder whenever one happened to exist, the
32/// meaning of `ryra add forgejo` would depend on your cwd, and a planted
33/// `forgejo/service.toml` (which can run arbitrary build/run commands) could
34/// hijack a trusted registry name. A bare word is always a registry ref.
35pub fn is_path_like(input: &str) -> bool {
36    input == "."
37        || input == ".."
38        || input.starts_with("./")
39        || input.starts_with("../")
40        || input.starts_with('/')
41        || input.starts_with('~')
42}
43
44/// Build a [`ServiceRef::Path`] from a directory, reading its `service.toml`
45/// for the canonical service name. The path is absolutized so the install
46/// record survives a later `cd`.
47pub fn path_ref(dir: &Path) -> Result<ServiceRef> {
48    let svc = registry::load_project_service(dir)?;
49    let abs = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
50    Ok(ServiceRef::Path {
51        dir: abs,
52        name: svc.def.service.name,
53    })
54}
55
56impl ServiceRef {
57    /// Parse a service reference from a string.
58    ///
59    /// - `"forgejo"` → `Default("forgejo")`
60    /// - `"acme/forgejo"` → `Custom { registry: "acme", service: "forgejo" }`
61    /// - `""`, `"/forgejo"`, `"acme/"`, `"acme/sub/forgejo"` → error
62    pub fn parse(input: &str) -> Result<Self> {
63        let parts: Vec<&str> = input.split('/').collect();
64        match parts.as_slice() {
65            [""] => Err(Error::InvalidServiceRef(
66                "service reference cannot be empty".to_string(),
67            )),
68            [name] => {
69                if name.is_empty() {
70                    Err(Error::InvalidServiceRef(
71                        "service reference cannot be empty".to_string(),
72                    ))
73                } else {
74                    Ok(ServiceRef::Default((*name).to_string()))
75                }
76            }
77            [registry, service] => {
78                if registry.is_empty() {
79                    return Err(Error::InvalidServiceRef(format!(
80                        "registry name cannot be empty in reference '{input}'"
81                    )));
82                }
83                if service.is_empty() {
84                    return Err(Error::InvalidServiceRef(format!(
85                        "service name cannot be empty in reference '{input}'"
86                    )));
87                }
88                Ok(ServiceRef::Custom {
89                    registry: (*registry).to_string(),
90                    service: (*service).to_string(),
91                })
92            }
93            _ => Err(Error::InvalidServiceRef(format!(
94                "invalid service reference '{input}': expected 'service' or 'registry/service'"
95            ))),
96        }
97    }
98
99    /// Returns the service name part of this reference.
100    pub fn service_name(&self) -> &str {
101        match self {
102            ServiceRef::Default(name) => name,
103            ServiceRef::Custom { service, .. } => service,
104            ServiceRef::Path { name, .. } => name,
105        }
106    }
107
108    /// Returns the registry name for this reference.
109    ///
110    /// Returns `"default"` for default-registry services. For a local path
111    /// install it returns the project directory, which is what gets recorded in
112    /// metadata so `ryra upgrade` can re-read the same `service.toml`.
113    pub fn registry_name(&self) -> &str {
114        match self {
115            ServiceRef::Default(_) => crate::paths::REGISTRY_DEFAULT,
116            ServiceRef::Custom { registry, .. } => registry,
117            ServiceRef::Path { dir, .. } => dir.to_str().unwrap_or("local"),
118        }
119    }
120}
121
122/// Resolve the on-disk directory of the default registry.
123///
124/// If `RYRA_REGISTRY_DIR` is set to an existing directory, that path is
125/// returned as-is (no clone, no pull) — the escape hatch tests use to inject
126/// `/opt/ryra-test-registry` inside the VM without network access. Otherwise
127/// the registry is cloned (or pulled) from [`DEFAULT_REGISTRY_URL`] into
128/// `<cache_dir>/default/`.
129/// The default registry's local dir *if it's already cloned* (or pointed at by
130/// `REGISTRY_DIR_ENV`), without any network fetch. For read-only callers like
131/// `ryra doctor` that want to inspect the cached registry but must never block
132/// on a clone/pull. `None` = not present locally; the caller skips its check.
133pub fn cached_default_registry_dir(cache_dir: &Path) -> Option<PathBuf> {
134    if let Ok(override_path) = std::env::var(REGISTRY_DIR_ENV) {
135        let path = PathBuf::from(override_path);
136        if path.is_dir() {
137            return Some(path);
138        }
139    }
140    let dest = cache_dir.join("default");
141    dest.is_dir().then_some(dest)
142}
143
144pub async fn resolve_default_registry_dir(cache_dir: &Path) -> Result<PathBuf> {
145    if let Ok(override_path) = std::env::var(REGISTRY_DIR_ENV) {
146        let path = PathBuf::from(override_path);
147        if path.is_dir() {
148            return Ok(path);
149        }
150    }
151
152    let dest = cache_dir.join("default");
153    registry::fetch::clone_or_pull(DEFAULT_REGISTRY_URL, &dest).await?;
154    Ok(dest)
155}
156
157/// Resolve the registry directory for a service reference.
158///
159/// - For `Default`: see [`resolve_default_registry_dir`].
160/// - For `Custom`: looks up the registry name in `config.registries` and clones/pulls it.
161pub async fn resolve_registry_dir(
162    service_ref: &ServiceRef,
163    config: &Config,
164    cache_dir: &Path,
165) -> Result<PathBuf> {
166    match service_ref {
167        // A local project: the directory *is* the source, no clone/pull.
168        ServiceRef::Path { dir, .. } => Ok(dir.clone()),
169        ServiceRef::Default(_) => resolve_default_registry_dir(cache_dir).await,
170        ServiceRef::Custom { registry, .. } => {
171            let entry = config
172                .registries
173                .iter()
174                .find(|r| r.name == *registry)
175                .ok_or_else(|| Error::RegistryNotFound(registry.clone()))?;
176
177            let dest = cache_dir.join("registries").join(registry);
178            registry::fetch::clone_or_pull(&entry.url, &dest).await?;
179            Ok(dest)
180        }
181    }
182}
183
184/// Resolve a service from a registry, returning its definition and directory.
185///
186/// - For `Default`: finds the service in the default registry.
187/// - For `Custom`: finds the service in the named custom registry.
188pub async fn resolve_service(
189    service_ref: &ServiceRef,
190    config: &Config,
191    cache_dir: &Path,
192) -> Result<registry::RegistryService> {
193    let repo_dir = resolve_registry_dir(service_ref, config, cache_dir).await?;
194    registry::find_service(&repo_dir, service_ref.service_name())
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn parse_default_service() {
203        let r = ServiceRef::parse("forgejo").expect("should parse");
204        assert_eq!(r, ServiceRef::Default("forgejo".to_string()));
205        assert_eq!(r.service_name(), "forgejo");
206        assert_eq!(r.registry_name(), "default");
207    }
208
209    #[test]
210    fn path_detection_is_syntactic_only() {
211        // Explicit markers → local path.
212        for p in [".", "..", "./app", "../app", "/abs/app", "~/app"] {
213            assert!(is_path_like(p), "{p} should be treated as a path");
214        }
215        // Bare names and registry refs are NEVER paths, regardless of what
216        // directories exist in the cwd. This is the security property: a planted
217        // `./forgejo/` folder can't hijack `ryra add forgejo`.
218        for name in ["forgejo", "acme/forgejo", "caddy", "my-app", "a/b"] {
219            assert!(!is_path_like(name), "{name} must stay a registry ref");
220        }
221    }
222
223    #[test]
224    fn parse_custom_service() {
225        let r = ServiceRef::parse("acme/forgejo").expect("should parse");
226        assert_eq!(
227            r,
228            ServiceRef::Custom {
229                registry: "acme".to_string(),
230                service: "forgejo".to_string(),
231            }
232        );
233        assert_eq!(r.service_name(), "forgejo");
234        assert_eq!(r.registry_name(), "acme");
235    }
236
237    #[test]
238    fn parse_empty_fails() {
239        let err = ServiceRef::parse("").expect_err("empty input should fail");
240        let msg = err.to_string();
241        assert!(
242            msg.contains("empty"),
243            "expected 'empty' in error message, got: {msg}"
244        );
245    }
246
247    #[test]
248    fn parse_empty_parts_fails() {
249        let err = ServiceRef::parse("/forgejo").expect_err("leading slash should fail");
250        let msg = err.to_string();
251        assert!(
252            msg.contains("empty"),
253            "expected 'empty' in error for '/forgejo', got: {msg}"
254        );
255
256        let err = ServiceRef::parse("acme/").expect_err("trailing slash should fail");
257        let msg = err.to_string();
258        assert!(
259            msg.contains("empty"),
260            "expected 'empty' in error for 'acme/', got: {msg}"
261        );
262    }
263
264    #[test]
265    fn parse_too_many_slashes_fails() {
266        let err = ServiceRef::parse("acme/sub/forgejo").expect_err("too many slashes should fail");
267        let msg = err.to_string();
268        assert!(
269            msg.contains("invalid"),
270            "expected 'invalid' in error message, got: {msg}"
271        );
272    }
273
274    #[test]
275    fn env_override_returns_path_directly() {
276        // SAFETY: this test sets a process-global env var; running multiple
277        // env-mutating tests in parallel within the same process can race.
278        // Cargo runs tests in threads, so we scope to a tempdir that
279        // exists for the duration of the call.
280        let tmp = tempfile::TempDir::new().expect("tempdir");
281        let cache = tempfile::TempDir::new().expect("cache tempdir");
282        // SAFETY: tests in this module are single-threaded by Cargo's
283        // default scheduler; setting env here doesn't escape the call.
284        unsafe { std::env::set_var(REGISTRY_DIR_ENV, tmp.path()) };
285
286        let rt = tokio::runtime::Runtime::new().expect("runtime");
287        let resolved = rt
288            .block_on(resolve_default_registry_dir(cache.path()))
289            .expect("resolve");
290        assert_eq!(resolved, tmp.path());
291
292        unsafe { std::env::remove_var(REGISTRY_DIR_ENV) };
293    }
294}