Skip to main content

debian_workspace/
fs_workspace.rs

1/// A `Workspace` implementation that operates on a directory on disk.
2use crate::Error;
3use crate::Version;
4use crate::workspace::{Editor, Workspace};
5use debian_changelog::ChangeLog;
6use debian_control::lossless::Control;
7use debian_copyright::lossless::Copyright;
8use debian_watch::parse::ParsedWatchFile;
9use makefile_lossless::Makefile;
10use std::fs;
11use std::io;
12use std::path::{Path, PathBuf};
13use toml_edit::DocumentMut;
14
15/// A [`Workspace`] backed by a directory on disk.
16///
17/// This is the implementation used by the `lintian-brush` CLI. The CLI's
18/// fixer harness materialises the working tree to disk before invoking a
19/// fixer; this workspace then operates on that directory, and breezyshim
20/// picks up the resulting changes outside the fixer.
21///
22/// It contains no `breezyshim` types and so is safe to depend on from hosts
23/// that don't want a Python runtime.
24pub struct FsWorkspace {
25    base_path: PathBuf,
26    package: Option<String>,
27    version: Option<Version>,
28}
29
30impl FsWorkspace {
31    /// Create a new tree-backed workspace.
32    ///
33    /// * `base_path` — absolute filesystem path of the package root (the
34    ///   directory containing `debian/`).
35    /// * `package`, `version` — taken from `debian/changelog` by the caller.
36    ///   Pass `None` when the caller hasn't read the changelog (e.g. tests, or
37    ///   tools that don't surface package metadata to their detectors).
38    pub fn new(
39        base_path: impl Into<PathBuf>,
40        package: Option<String>,
41        version: Option<Version>,
42    ) -> Self {
43        Self {
44            base_path: base_path.into(),
45            package,
46            version,
47        }
48    }
49
50    /// The absolute on-disk root of the package.
51    pub fn base_path(&self) -> &Path {
52        &self.base_path
53    }
54
55    fn full_path(&self, rel: &Path) -> PathBuf {
56        self.base_path.join(rel)
57    }
58}
59
60/// `Editor` impl for a parsed file backed by a path on disk.
61///
62/// Holds the parsed value, its original on-disk text (so we can detect
63/// changes), and the absolute path to write back to. Serialisation goes
64/// through the type's `Display` impl.
65struct FsEditor<T> {
66    parsed: T,
67    original: String,
68    path: PathBuf,
69    committed: bool,
70}
71
72impl<T> std::ops::Deref for FsEditor<T> {
73    type Target = T;
74    fn deref(&self) -> &T {
75        &self.parsed
76    }
77}
78
79impl<T> std::ops::DerefMut for FsEditor<T> {
80    fn deref_mut(&mut self) -> &mut T {
81        &mut self.parsed
82    }
83}
84
85impl<T: std::fmt::Display> FsEditor<T> {
86    fn flush(&mut self) -> Result<(), Error> {
87        if self.committed {
88            return Ok(());
89        }
90        let new_text = self.parsed.to_string();
91        if new_text != self.original {
92            fs::write(&self.path, &new_text)?;
93        }
94        self.committed = true;
95        Ok(())
96    }
97}
98
99impl<T: std::fmt::Display + 'static> Editor<T> for FsEditor<T> {
100    fn commit(mut self: Box<Self>) -> Result<(), Error> {
101        self.flush()
102    }
103}
104
105impl<T> Drop for FsEditor<T> {
106    fn drop(&mut self) {
107        // Tree-mode fixers traditionally rely on implicit write-back. We
108        // *don't* attempt it here because we'd have no way to surface a
109        // serialisation failure: the tests would silently lose data. Callers
110        // must invoke `commit` explicitly. If they forgot, log loudly.
111        if !self.committed {
112            tracing::warn!(
113                "Workspace Editor for {} dropped without commit; \
114                 changes (if any) discarded",
115                self.path.display()
116            );
117        }
118    }
119}
120
121impl Workspace for FsWorkspace {
122    fn package(&self) -> Option<&str> {
123        self.package.as_deref()
124    }
125
126    fn current_version(&self) -> Option<&Version> {
127        self.version.as_ref()
128    }
129
130    fn parsed_control(&self) -> Result<Control, Error> {
131        let path = self.full_path(Path::new("debian/control"));
132        let text = fs::read_to_string(&path)?;
133        let (control, errors) = Control::read_relaxed(text.as_bytes())
134            .map_err(|e| Error::Parse(format!("Failed to parse {}: {}", path.display(), e)))?;
135        if !errors.is_empty() {
136            tracing::debug!(
137                "{} has {} parse warning(s): {}",
138                path.display(),
139                errors.len(),
140                errors.join("; ")
141            );
142        }
143        Ok(control)
144    }
145
146    fn parsed_changelog(&self) -> Result<ChangeLog, Error> {
147        let path = self.full_path(Path::new("debian/changelog"));
148        let text = fs::read_to_string(&path)?;
149        ChangeLog::read_relaxed(text.as_bytes())
150            .map_err(|e| Error::Parse(format!("Failed to parse {}: {}", path.display(), e)))
151    }
152
153    fn parsed_copyright(&self) -> Result<Copyright, Error> {
154        let path = self.full_path(Path::new("debian/copyright"));
155        let text = fs::read_to_string(&path)?;
156        let (copyright, errors) = Copyright::from_str_relaxed(&text)
157            .map_err(|e| Error::Parse(format!("Failed to parse {}: {:?}", path.display(), e)))?;
158        if !errors.is_empty() {
159            tracing::debug!(
160                "{} has {} parse warning(s): {}",
161                path.display(),
162                errors.len(),
163                errors.join("; ")
164            );
165        }
166        Ok(copyright)
167    }
168
169    fn parsed_upstream_metadata(&self) -> Result<yaml_edit::YamlFile, Error> {
170        use std::str::FromStr;
171        let path = self.full_path(Path::new("debian/upstream/metadata"));
172        let text = fs::read_to_string(&path)?;
173        yaml_edit::YamlFile::from_str(&text)
174            .map_err(|e| Error::Parse(format!("Failed to parse {}: {}", path.display(), e)))
175    }
176
177    fn parsed_watch(&self) -> Result<ParsedWatchFile, Error> {
178        let path = self.full_path(Path::new("debian/watch"));
179        let text = fs::read_to_string(&path)?;
180        debian_watch::parse::parse(&text)
181            .map_err(|e| Error::Parse(format!("Failed to parse {}: {:?}", path.display(), e)))
182    }
183
184    fn parsed_rules(&self) -> Result<Makefile, Error> {
185        let path = self.full_path(Path::new("debian/rules"));
186        let bytes = fs::read(&path)?;
187        Makefile::read_relaxed(bytes.as_slice())
188            .map_err(|e| Error::Parse(format!("Failed to parse {}: {}", path.display(), e)))
189    }
190
191    fn source_format(&self) -> Result<Option<String>, Error> {
192        match self.read_file(Path::new("debian/source/format"))? {
193            Some(b) => Ok(std::str::from_utf8(&b)
194                .ok()
195                .map(|s| s.trim().to_string())
196                .filter(|s| !s.is_empty())),
197            None => Ok(None),
198        }
199    }
200
201    fn control(&self) -> Result<Box<dyn Editor<Control> + '_>, Error> {
202        let path = self.full_path(Path::new("debian/control"));
203        let original = fs::read_to_string(&path)?;
204        let parsed: Control = original.parse().map_err(|e: deb822_lossless::ParseError| {
205            Error::Parse(format!("Failed to parse {}: {}", path.display(), e))
206        })?;
207        Ok(Box::new(FsEditor {
208            parsed,
209            original,
210            path,
211            committed: false,
212        }))
213    }
214
215    fn changelog(&self) -> Result<Box<dyn Editor<ChangeLog> + '_>, Error> {
216        let path = self.full_path(Path::new("debian/changelog"));
217        let original = fs::read_to_string(&path)?;
218        let parsed = ChangeLog::read_relaxed(original.as_bytes())
219            .map_err(|e| Error::Parse(format!("Failed to parse {}: {}", path.display(), e)))?;
220        Ok(Box::new(FsEditor {
221            parsed,
222            original,
223            path,
224            committed: false,
225        }))
226    }
227
228    fn debcargo(&self) -> Result<Option<Box<dyn Editor<DocumentMut> + '_>>, Error> {
229        let path = self.full_path(Path::new("debian/debcargo.toml"));
230        let original = match fs::read_to_string(&path) {
231            Ok(s) => s,
232            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
233            Err(e) => return Err(Error::Io(e)),
234        };
235        let parsed: DocumentMut = original
236            .parse()
237            .map_err(|e| Error::Parse(format!("Failed to parse {}: {}", path.display(), e)))?;
238        Ok(Some(Box::new(FsEditor {
239            parsed,
240            original,
241            path,
242            committed: false,
243        })))
244    }
245
246    fn read_file(&self, rel: &Path) -> Result<Option<std::borrow::Cow<'_, [u8]>>, Error> {
247        let path = self.full_path(rel);
248        match fs::read(&path) {
249            Ok(bytes) => Ok(Some(std::borrow::Cow::Owned(bytes))),
250            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
251            Err(e) => Err(Error::Io(e)),
252        }
253    }
254
255    fn write_file(&self, rel: &Path, content: &[u8]) -> Result<(), Error> {
256        let path = self.full_path(rel);
257        fs::write(&path, content)?;
258        Ok(())
259    }
260
261    fn list_dir(&self, rel: &Path) -> Result<Option<Vec<String>>, Error> {
262        let path = self.full_path(rel);
263        let read_dir = match fs::read_dir(&path) {
264            Ok(it) => it,
265            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
266            Err(e) => return Err(Error::Io(e)),
267        };
268        let mut names = Vec::new();
269        for entry in read_dir {
270            let entry = entry?;
271            names.push(entry.file_name().to_string_lossy().into_owned());
272        }
273        Ok(Some(names))
274    }
275
276    fn walk_dir(&self, rel: &Path) -> Result<Option<Vec<PathBuf>>, Error> {
277        let abs = self.full_path(rel);
278        if !abs.exists() {
279            return Ok(None);
280        }
281        let mut out = Vec::new();
282        let mut stack: Vec<PathBuf> = vec![abs.clone()];
283        while let Some(dir) = stack.pop() {
284            let read_dir = match fs::read_dir(&dir) {
285                Ok(it) => it,
286                Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
287                Err(e) => return Err(Error::Io(e)),
288            };
289            for entry in read_dir {
290                let entry = entry?;
291                let ft = entry.file_type()?;
292                let path = entry.path();
293                if ft.is_dir() {
294                    stack.push(path);
295                } else if ft.is_file() {
296                    let rel_path = path
297                        .strip_prefix(&self.base_path)
298                        .map(|p| p.to_path_buf())
299                        .unwrap_or(path);
300                    out.push(rel_path);
301                }
302                // Skip symlinks and other non-regular entries.
303            }
304        }
305        Ok(Some(out))
306    }
307
308    fn file_mode(&self, rel: &Path) -> Result<Option<u32>, Error> {
309        use std::os::unix::fs::PermissionsExt;
310        let path = self.full_path(rel);
311        match fs::metadata(&path) {
312            Ok(m) => Ok(Some(m.permissions().mode())),
313            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
314            Err(e) => Err(Error::Io(e)),
315        }
316    }
317
318    fn base_path(&self) -> Option<&Path> {
319        Some(&self.base_path)
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use std::str::FromStr;
327    use tempfile::TempDir;
328
329    fn make_pkg(dir: &Path) {
330        let debian = dir.join("debian");
331        fs::create_dir_all(&debian).unwrap();
332        fs::write(
333            debian.join("control"),
334            "Source: foo\n\nPackage: foo\nDescription: bar\n bar\n",
335        )
336        .unwrap();
337        fs::write(
338            debian.join("changelog"),
339            "foo (1.0) unstable; urgency=medium\n\n  * Initial.\n\n -- A B <a@b>  Mon, 01 Jan 2024 00:00:00 +0000\n",
340        )
341        .unwrap();
342    }
343
344    #[test]
345    fn tree_workspace_reads_and_writes_control() {
346        let tmp = TempDir::new().unwrap();
347        make_pkg(tmp.path());
348
349        let ws = FsWorkspace::new(
350            tmp.path(),
351            Some("foo".into()),
352            Some(Version::from_str("1.0").unwrap()),
353        );
354
355        {
356            let control = ws.control().unwrap();
357            let mut source = control.source().unwrap();
358            source.set_homepage(&url::Url::parse("https://example.com/").unwrap());
359            control.commit().unwrap();
360        }
361
362        let on_disk = fs::read_to_string(tmp.path().join("debian/control")).unwrap();
363        assert!(on_disk.contains("Homepage: https://example.com/"));
364    }
365
366    #[test]
367    fn tree_workspace_read_write_raw_file() {
368        let tmp = TempDir::new().unwrap();
369        make_pkg(tmp.path());
370
371        let ws = FsWorkspace::new(
372            tmp.path(),
373            Some("foo".into()),
374            Some(Version::from_str("1.0").unwrap()),
375        );
376
377        let p = Path::new("debian/control");
378        let bytes = ws.read_file(p).unwrap().unwrap();
379        assert!(bytes.starts_with(b"Source: foo"));
380
381        ws.write_file(Path::new("debian/x"), b"hello").unwrap();
382        let back = ws.read_file(Path::new("debian/x")).unwrap().unwrap();
383        assert_eq!(&*back, b"hello");
384
385        assert!(ws.read_file(Path::new("debian/missing")).unwrap().is_none());
386    }
387
388    #[test]
389    fn tree_workspace_missing_control_is_not_found() {
390        let tmp = TempDir::new().unwrap();
391        // Don't make_pkg — no debian/ at all.
392        let ws = FsWorkspace::new(
393            tmp.path(),
394            Some("foo".into()),
395            Some(Version::from_str("1.0").unwrap()),
396        );
397        assert!(matches!(ws.control(), Err(Error::NotFound)));
398    }
399
400    #[test]
401    fn tree_workspace_walk_dir_returns_relative_files() {
402        let tmp = TempDir::new().unwrap();
403        make_pkg(tmp.path());
404        // Add a subdirectory with a file to verify recursion.
405        let nested = tmp.path().join("debian/source");
406        fs::create_dir_all(&nested).unwrap();
407        fs::write(nested.join("format"), "3.0 (quilt)\n").unwrap();
408
409        let ws = FsWorkspace::new(
410            tmp.path(),
411            Some("foo".into()),
412            Some(Version::from_str("1.0").unwrap()),
413        );
414        let mut paths = ws.walk_dir(Path::new("debian")).unwrap().unwrap();
415        paths.sort();
416
417        assert_eq!(
418            paths,
419            vec![
420                PathBuf::from("debian/changelog"),
421                PathBuf::from("debian/control"),
422                PathBuf::from("debian/source/format"),
423            ]
424        );
425    }
426
427    #[test]
428    fn tree_workspace_walk_dir_missing_returns_none() {
429        let tmp = TempDir::new().unwrap();
430        let ws = FsWorkspace::new(
431            tmp.path(),
432            Some("foo".into()),
433            Some(Version::from_str("1.0").unwrap()),
434        );
435        assert!(ws.walk_dir(Path::new("debian")).unwrap().is_none());
436    }
437
438    #[test]
439    fn debcargo_absent_returns_none() {
440        let tmp = TempDir::new().unwrap();
441        make_pkg(tmp.path());
442        let ws = FsWorkspace::new(
443            tmp.path(),
444            Some("foo".into()),
445            Some(Version::from_str("1.0").unwrap()),
446        );
447        assert!(ws.parsed_debcargo().unwrap().is_none());
448        assert!(ws.debcargo().unwrap().is_none());
449    }
450
451    #[test]
452    fn debcargo_read_and_write() {
453        let tmp = TempDir::new().unwrap();
454        make_pkg(tmp.path());
455        let toml = "[source]\nvcs_git = \"https://salsa.debian.org/rust-team/debcargo-conf\"\n";
456        fs::write(tmp.path().join("debian/debcargo.toml"), toml).unwrap();
457
458        let ws = FsWorkspace::new(
459            tmp.path(),
460            Some("foo".into()),
461            Some(Version::from_str("1.0").unwrap()),
462        );
463
464        let doc = ws.parsed_debcargo().unwrap().unwrap();
465        assert_eq!(
466            doc["source"]["vcs_git"].as_str().unwrap(),
467            "https://salsa.debian.org/rust-team/debcargo-conf"
468        );
469
470        {
471            let mut editor = ws.debcargo().unwrap().unwrap();
472            editor["source"]["vcs_git"] =
473                toml_edit::value("https://salsa.debian.org/rust-team/debcargo-conf.git");
474            editor.commit().unwrap();
475        }
476
477        let on_disk = fs::read_to_string(tmp.path().join("debian/debcargo.toml")).unwrap();
478        assert_eq!(
479            on_disk,
480            "[source]\nvcs_git = \"https://salsa.debian.org/rust-team/debcargo-conf.git\"\n"
481        );
482    }
483}