aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-registry/src/path_repository.rs
blob: bf7131588dfd659e53e610a1d663c3fb3acfa502 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
//! Support for `type: path` repositories.
//!
//! Mirrors `Composer\Repository\PathRepository`: a path repo points at a
//! local directory containing a `composer.json`, and the resolver loads the
//! package from that file directly. Mozart does not yet support glob URLs or
//! the `versions` / `reference: none` options — only the bare
//! `{ type: path, url: ... }` form the installer fixtures exercise.
//!
//! Resolution model: a path repo is expanded into a synthetic
//! `type: package` [`RawRepository`] whose payload is the loaded composer.json
//! plus a `dist` block. After this expansion the rest of the registry treats
//! the package the same as any inline `type: package` entry — that is the
//! whole point of doing the work here rather than threading a new repo type
//! through the resolver / lockfile.
//!
//! `dist.reference` matches Composer's `hash('sha1', $json . serialize($options))`
//! where `$options` carries the auto-detected `relative` flag (true when the
//! original URL was not absolute). The same SHA-1 ends up in the lockfile, so
//! consumers comparing references against Composer-produced lockfiles see
//! byte-identical values.

use std::path::{Path, PathBuf};

use mozart_core::package::RawRepository;
use mozart_php_serialize::{Value as PhpValue, serialize as php_serialize};
use sha1::{Digest, Sha1};

/// Translate path repos in `repositories` into synthetic `type: package`
/// entries. Non-path entries are returned unchanged in original order.
///
/// `base_dir` is the directory used to resolve relative `url` values
/// (Composer's PHP code resolves these against the process cwd; in production
/// that equals the project root, in tests it equals the fixtures anchor).
///
/// Failures (missing directory, unreadable composer.json, missing
/// `name`/`version`) drop the offending entry silently — the rest of the
/// repository list still applies. This mirrors Composer's lenient
/// PathRepository, which logs a warning and moves on rather than aborting the
/// whole resolve.
pub fn expand_path_repositories(
    repositories: &[RawRepository],
    base_dir: &Path,
) -> Vec<RawRepository> {
    let mut out = Vec::with_capacity(repositories.len());
    for repo in repositories {
        if repo.repo_type != "path" {
            out.push(repo.clone());
            continue;
        }
        let Some(url) = repo.url.as_deref() else {
            continue;
        };
        let Some(synthetic) = load_path_package(url, base_dir) else {
            continue;
        };
        out.push(synthetic);
    }
    out
}

/// Read one path repo's `composer.json` and synthesize the inline-package
/// form. Returns `None` for any I/O or parse failure (Composer behaves the
/// same — `PathRepository::initialize` skips entries whose `composer.json`
/// is missing).
fn load_path_package(url: &str, base_dir: &Path) -> Option<RawRepository> {
    let resolved = resolve_path(url, base_dir);
    let composer_json_path = resolved.join("composer.json");
    let json = std::fs::read_to_string(&composer_json_path).ok()?;
    let mut package: serde_json::Value = serde_json::from_str(&json).ok()?;
    let obj = package.as_object_mut()?;

    // `version` is mandatory in the inline-package representation: without it
    // the resolver would skip the package. Composer's PathRepository falls
    // back to `dev-main` when no version is declared and no VCS is present;
    // mirror that so a path repo whose composer.json omits `version` still
    // produces a usable entry.
    if !obj.contains_key("version") {
        obj.insert(
            "version".to_string(),
            serde_json::Value::String("dev-main".to_string()),
        );
    }

    let is_relative = !Path::new(url).is_absolute();
    let reference = compute_path_reference(json.as_bytes(), is_relative);

    obj.insert(
        "dist".to_string(),
        serde_json::json!({
            "type": "path",
            "url": url,
            "reference": reference,
        }),
    );
    // Composer copies `symlink`/`relative` from `options` into
    // `transport-options`. We have no `options` to forward today but emit an
    // empty object so consumers reading the package see the same shape.
    obj.entry("transport-options")
        .or_insert_with(|| serde_json::json!({}));

    Some(RawRepository {
        repo_type: "package".to_string(),
        url: None,
        package: Some(serde_json::Value::Array(vec![package])),
        only: None,
        exclude: None,
        canonical: None,
        security_advisories: None,
    })
}

fn resolve_path(url: &str, base_dir: &Path) -> PathBuf {
    let p = Path::new(url);
    if p.is_absolute() {
        p.to_path_buf()
    } else {
        base_dir.join(p)
    }
}

/// Compose the SHA-1 reference Composer uses for path repos:
/// `sha1($json . serialize(['relative' => $isRelative]))`. The `relative`
/// flag is the only option Composer's auto-detection populates when the user
/// supplied no `options` block.
fn compute_path_reference(json_bytes: &[u8], is_relative: bool) -> String {
    let options = PhpValue::Array(vec![(
        PhpValue::String("relative".to_string()),
        PhpValue::Bool(is_relative),
    )]);
    let serialized = php_serialize(&options);
    let mut hasher = Sha1::new();
    hasher.update(json_bytes);
    hasher.update(serialized.as_bytes());
    let bytes = hasher.finalize();
    let mut hex = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        use std::fmt::Write;
        let _ = write!(&mut hex, "{:02x}", b);
    }
    hex
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn computes_known_reference_for_plugin_a_fixture() {
        // Fixture used by partial-update-loads-root-aliases-for-path-repos.test.
        // Expected reference (`b133081...`) is what PHP's
        // `hash('sha1', file_get_contents($composerJson) . serialize(['relative' => true]))`
        // produces for this file — pin it here so reference computation
        // changes can't drift silently from Composer.
        let composer_json_path = Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("../../composer/tests/Composer/Test/Fixtures/functional/installed-versions/plugin-a/composer.json");
        let bytes = std::fs::read(&composer_json_path).expect("fixture composer.json must exist");
        let reference = compute_path_reference(&bytes, true);
        assert!(
            reference.starts_with("b133081"),
            "unexpected reference: {reference}"
        );
    }

    #[test]
    fn relative_url_resolves_against_base_dir_and_emits_synthetic_package_repo() {
        let temp = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(temp.path().join("pkg-dir")).unwrap();
        std::fs::write(
            temp.path().join("pkg-dir").join("composer.json"),
            r#"{"name": "vendor/pkg", "version": "1.2.3"}"#,
        )
        .unwrap();

        let input = vec![RawRepository {
            repo_type: "path".to_string(),
            url: Some("pkg-dir".to_string()),
            package: None,
            only: None,
            exclude: None,
            canonical: None,
            security_advisories: None,
        }];
        let expanded = expand_path_repositories(&input, temp.path());
        assert_eq!(expanded.len(), 1);
        assert_eq!(expanded[0].repo_type, "package");

        let pkgs = expanded[0]
            .package
            .as_ref()
            .expect("expanded entry must carry a package payload")
            .as_array()
            .expect("payload should be an array");
        assert_eq!(pkgs.len(), 1);
        let pkg = &pkgs[0];
        assert_eq!(pkg["name"], "vendor/pkg");
        assert_eq!(pkg["version"], "1.2.3");
        assert_eq!(pkg["dist"]["type"], "path");
        assert_eq!(pkg["dist"]["url"], "pkg-dir");
        assert!(
            pkg["dist"]["reference"]
                .as_str()
                .map(|s| s.len() == 40)
                .unwrap_or(false),
            "reference should be a 40-char SHA-1"
        );
    }

    #[test]
    fn missing_composer_json_drops_the_entry() {
        let temp = tempfile::tempdir().unwrap();
        let input = vec![RawRepository {
            repo_type: "path".to_string(),
            url: Some("does-not-exist".to_string()),
            package: None,
            only: None,
            exclude: None,
            canonical: None,
            security_advisories: None,
        }];
        let expanded = expand_path_repositories(&input, temp.path());
        assert!(expanded.is_empty());
    }

    #[test]
    fn non_path_repos_pass_through_unchanged() {
        let input = vec![RawRepository {
            repo_type: "vcs".to_string(),
            url: Some("https://example.com/repo.git".to_string()),
            package: None,
            only: None,
            exclude: None,
            canonical: None,
            security_advisories: None,
        }];
        let expanded = expand_path_repositories(&input, Path::new("/tmp"));
        assert_eq!(expanded.len(), 1);
        assert_eq!(expanded[0].repo_type, "vcs");
        assert_eq!(
            expanded[0].url.as_deref(),
            Some("https://example.com/repo.git")
        );
    }
}