Skip to main content

deps_dart/
lockfile.rs

1//! pubspec.lock file parsing.
2
3use deps_core::error::{DepsError, Result};
4use deps_core::lockfile::{
5    LockFileProvider, ResolvedPackage, ResolvedPackages, ResolvedSource,
6    locate_lockfile_for_manifest, read_lockfile_content,
7};
8use std::path::{Path, PathBuf};
9use tower_lsp_server::ls_types::Uri;
10use yaml_rust2::{Yaml, YamlLoader};
11
12pub struct PubspecLockParser;
13
14impl PubspecLockParser {
15    const LOCKFILE_NAMES: &'static [&'static str] = &["pubspec.lock"];
16}
17
18impl LockFileProvider for PubspecLockParser {
19    fn locate_lockfile(&self, manifest_uri: &Uri) -> Option<PathBuf> {
20        locate_lockfile_for_manifest(manifest_uri, Self::LOCKFILE_NAMES)
21    }
22
23    fn parse_lockfile<'a>(
24        &'a self,
25        lockfile_path: &'a Path,
26    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ResolvedPackages>> + Send + 'a>>
27    {
28        Box::pin(async move {
29            tracing::debug!("Parsing pubspec.lock: {}", lockfile_path.display());
30
31            let content = read_lockfile_content(lockfile_path, "pubspec.lock").await?;
32
33            parse_pubspec_lock(&content)
34        })
35    }
36}
37
38pub fn parse_pubspec_lock(content: &str) -> Result<ResolvedPackages> {
39    if let Err(depth) =
40        deps_core::check_yaml_nesting_depth(content, deps_core::MAX_YAML_NESTING_DEPTH)
41    {
42        return Err(DepsError::ParseError {
43            file_type: "pubspec.lock".into(),
44            source: Box::new(std::io::Error::other(format!(
45                "YAML nesting depth {depth} exceeds maximum of {}",
46                deps_core::MAX_YAML_NESTING_DEPTH
47            ))),
48        });
49    }
50
51    if let Err(bytes) = deps_core::check_yaml_expansion(content, deps_core::MAX_YAML_EXPANDED_BYTES)
52    {
53        return Err(DepsError::ParseError {
54            file_type: "pubspec.lock".into(),
55            source: Box::new(std::io::Error::other(format!(
56                "YAML expansion {bytes} bytes exceeds maximum of {} bytes",
57                deps_core::MAX_YAML_EXPANDED_BYTES
58            ))),
59        });
60    }
61
62    let mut packages = ResolvedPackages::new();
63
64    let docs = YamlLoader::load_from_str(content).map_err(|e| DepsError::ParseError {
65        file_type: "pubspec.lock".into(),
66        source: Box::new(std::io::Error::other(e.to_string())),
67    })?;
68
69    let doc = match docs.first() {
70        Some(d) => d,
71        None => return Ok(packages),
72    };
73
74    if let Yaml::Hash(pkgs) = &doc["packages"] {
75        for (name_yaml, entry) in pkgs {
76            let Some(name) = name_yaml.as_str() else {
77                continue;
78            };
79            let Some(version) = entry["version"].as_str() else {
80                continue;
81            };
82
83            let source_type = entry["source"].as_str().unwrap_or("hosted");
84            let source = match source_type {
85                "hosted" => {
86                    let url = entry["description"]["url"]
87                        .as_str()
88                        .unwrap_or("https://pub.dev")
89                        .to_string();
90                    ResolvedSource::Registry {
91                        url,
92                        checksum: String::new(),
93                    }
94                }
95                "git" => {
96                    let url = entry["description"]["url"]
97                        .as_str()
98                        .unwrap_or("")
99                        .to_string();
100                    let rev = entry["description"]["resolved-ref"]
101                        .as_str()
102                        .unwrap_or("")
103                        .to_string();
104                    ResolvedSource::Git { url, rev }
105                }
106                "path" => {
107                    let path = entry["description"]["path"]
108                        .as_str()
109                        .unwrap_or("")
110                        .to_string();
111                    ResolvedSource::Path { path }
112                }
113                _ => ResolvedSource::Registry {
114                    url: "https://pub.dev".to_string(),
115                    checksum: String::new(),
116                },
117            };
118
119            // Remove surrounding quotes from version if present
120            let version = version.trim_matches('"').to_string();
121
122            packages.insert(ResolvedPackage {
123                name: name.to_string(),
124                version,
125                source,
126                dependencies: vec![],
127            });
128        }
129    }
130
131    tracing::info!("Parsed pubspec.lock: {} packages", packages.len());
132
133    Ok(packages)
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn test_parse_simple_lock() {
142        let lock = r#"
143packages:
144  http:
145    dependency: "direct main"
146    description:
147      name: http
148      url: "https://pub.dev"
149    source: hosted
150    version: "1.2.0"
151  provider:
152    dependency: "direct main"
153    description:
154      name: provider
155      url: "https://pub.dev"
156    source: hosted
157    version: "6.1.2"
158"#;
159        let packages = parse_pubspec_lock(lock).unwrap();
160        assert_eq!(packages.len(), 2);
161        assert_eq!(packages.get_version("http"), Some("1.2.0"));
162        assert_eq!(packages.get_version("provider"), Some("6.1.2"));
163    }
164
165    #[test]
166    fn test_parse_git_source() {
167        let lock = r#"
168packages:
169  my_pkg:
170    dependency: "direct main"
171    description:
172      url: "https://github.com/user/repo.git"
173      resolved-ref: abc123
174    source: git
175    version: "0.1.0"
176"#;
177        let packages = parse_pubspec_lock(lock).unwrap();
178        let pkg = packages.get("my_pkg").unwrap();
179        match &pkg.source {
180            ResolvedSource::Git { url, rev } => {
181                assert_eq!(url, "https://github.com/user/repo.git");
182                assert_eq!(rev, "abc123");
183            }
184            _ => panic!("Expected Git source"),
185        }
186    }
187
188    #[test]
189    fn test_parse_path_source() {
190        let lock = r#"
191packages:
192  local_pkg:
193    dependency: "direct main"
194    description:
195      path: "../local_pkg"
196    source: path
197    version: "0.1.0"
198"#;
199        let packages = parse_pubspec_lock(lock).unwrap();
200        let pkg = packages.get("local_pkg").unwrap();
201        match &pkg.source {
202            ResolvedSource::Path { path } => {
203                assert_eq!(path, "../local_pkg");
204            }
205            _ => panic!("Expected Path source"),
206        }
207    }
208
209    #[test]
210    fn test_parse_empty_lock() {
211        let lock = "";
212        let packages = parse_pubspec_lock(lock).unwrap();
213        assert!(packages.is_empty());
214    }
215
216    #[test]
217    fn test_deeply_nested_lock_rejected_not_crashed() {
218        // 6000 comfortably exceeds the empirically bisected real
219        // `yaml-rust2` 0.12 crash threshold for this exact payload shape
220        // (compact dash chain: aborts at depth 4536 on a 2 MiB debug
221        // stack), so this is a genuine regression test for the pre-fix
222        // SIGABRT, not just proof the 64 limit fires.
223        let lock = format!("{}1", "- ".repeat(6000));
224        let result = parse_pubspec_lock(&lock);
225        assert!(result.is_err());
226    }
227
228    #[test]
229    fn test_deeply_nested_lock_with_apostrophe_rejected_not_crashed() {
230        // impl-critic C1: a `'`/`"` inside a plain scalar earlier in the
231        // file must not blind the guard to real nesting later in the file.
232        let lock = format!(
233            "packages:\n  http:\n    description: it doesn't matter\n{}1",
234            "- ".repeat(6000)
235        );
236        let result = parse_pubspec_lock(&lock);
237        assert!(result.is_err());
238    }
239
240    #[test]
241    fn test_anchor_alias_expansion_bomb_rejected_not_oomed() {
242        // #175: a shallow (depth-2) doubling chain of anchor/alias
243        // references, which `check_yaml_nesting_depth` cannot catch since
244        // nesting depth stays constant — must be rejected by the expansion
245        // budget instead of handed to `YamlLoader::load_from_str`, which
246        // would OOM/SIGKILL the process on this shape.
247        let mut lock = String::from("packages:\n  a0: &a0 [x, x]\n");
248        for i in 1..=30 {
249            lock.push_str(&format!(
250                "  a{i}: &a{i} [*a{prev}, *a{prev}]\n",
251                prev = i - 1
252            ));
253        }
254        let result = parse_pubspec_lock(&lock);
255        // Asserting on the message (not just `is_err()`) pins that this is
256        // rejected by the expansion guard specifically, so a future reorder
257        // that lets a different guard fire first would be caught.
258        let err = result.expect_err("expected the expansion budget to reject this");
259        assert!(
260            err.to_string().contains("YAML expansion"),
261            "unexpected error message: {err}"
262        );
263    }
264
265    #[test]
266    fn test_asterisk_in_description_not_misread_as_alias() {
267        let lock = r#"
268packages:
269  http:
270    dependency: "direct main"
271    description: A package for *multiplier* http requests
272    source: hosted
273    version: "1.2.0"
274"#;
275        let result = parse_pubspec_lock(lock);
276        assert!(result.is_ok());
277    }
278
279    #[test]
280    fn test_realistic_pubspec_lock_still_parses() {
281        let lock = r#"
282packages:
283  http:
284    dependency: "direct main"
285    description:
286      name: http
287      url: "https://pub.dev"
288    source: hosted
289    version: "1.2.0"
290"#;
291        let packages = parse_pubspec_lock(lock).unwrap();
292        assert_eq!(packages.get_version("http"), Some("1.2.0"));
293    }
294
295    #[test]
296    fn test_locate_lockfile() {
297        let temp_dir = tempfile::tempdir().unwrap();
298        let manifest_path = temp_dir.path().join("pubspec.yaml");
299        let lock_path = temp_dir.path().join("pubspec.lock");
300
301        std::fs::write(&manifest_path, "name: test").unwrap();
302        std::fs::write(&lock_path, "packages:\n").unwrap();
303
304        let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
305        let parser = PubspecLockParser;
306
307        let located = parser.locate_lockfile(&manifest_uri);
308        assert!(located.is_some());
309        assert_eq!(located.unwrap(), lock_path);
310    }
311
312    #[test]
313    fn test_locate_lockfile_not_found() {
314        let temp_dir = tempfile::tempdir().unwrap();
315        let manifest_path = temp_dir.path().join("pubspec.yaml");
316        std::fs::write(&manifest_path, "name: test").unwrap();
317
318        let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
319        let parser = PubspecLockParser;
320
321        assert!(parser.locate_lockfile(&manifest_uri).is_none());
322    }
323
324    #[tokio::test]
325    async fn test_parse_lockfile_from_file() {
326        let temp_dir = tempfile::tempdir().unwrap();
327        let lock_path = temp_dir.path().join("pubspec.lock");
328
329        let content = r#"
330packages:
331  http:
332    dependency: "direct main"
333    description:
334      name: http
335      url: "https://pub.dev"
336    source: hosted
337    version: "1.2.0"
338"#;
339        std::fs::write(&lock_path, content).unwrap();
340
341        let parser = PubspecLockParser;
342        let packages = parser.parse_lockfile(&lock_path).await.unwrap();
343        assert_eq!(packages.len(), 1);
344        assert_eq!(packages.get_version("http"), Some("1.2.0"));
345    }
346}