swiftide_docker_executor/
context_builder.rs

1use std::{
2    os::unix::fs::MetadataExt as _,
3    path::{Path, PathBuf},
4};
5
6use ignore::gitignore::{Gitignore, GitignoreBuilder};
7// use ignore::{overrides::OverrideBuilder, WalkBuilder};
8use tokio::io::AsyncReadExt as _;
9use tokio_tar::{Builder, EntryType, Header};
10use walkdir::{DirEntry, WalkDir};
11
12use crate::ContextError;
13
14type ContextArchive = Vec<u8>;
15
16#[derive(Debug)]
17pub struct ContextBuilder {
18    context_path: PathBuf,
19    ignore: Gitignore,
20    dockerfile: PathBuf,
21    global: Option<Gitignore>,
22}
23
24impl ContextBuilder {
25    pub fn from_path(
26        context_path: impl Into<PathBuf>,
27        dockerfile: impl AsRef<Path>,
28    ) -> Result<Self, ContextError> {
29        let path = context_path.into();
30        let mut gitignore = GitignoreBuilder::new(&path);
31
32        if let Some(err) = gitignore.add(path.join(".gitignore")) {
33            tracing::warn!(?err, "Error adding .gitignore");
34        }
35        if let Some(err) = gitignore.add(path.join(".dockerignore")) {
36            tracing::warn!(?err, "Error adding .dockerignore");
37        }
38
39        let gitignore = gitignore.build()?;
40
41        let (global_gitignore, maybe_error) = Gitignore::global();
42        let maybe_global = if let Some(err) = maybe_error {
43            tracing::warn!(?err, "Error adding global gitignore");
44            None
45        } else {
46            Some(global_gitignore)
47        };
48
49        Ok(Self {
50            dockerfile: dockerfile.as_ref().to_path_buf(),
51            context_path: path,
52            ignore: gitignore,
53            global: maybe_global,
54        })
55    }
56
57    fn is_ignored(&self, path: impl AsRef<Path>) -> bool {
58        let Ok(relative_path) = path.as_ref().strip_prefix(&self.context_path) else {
59            tracing::debug!(
60                "not ignoring {path} as it seems to be not prefixed by {prefix}",
61                path = path.as_ref().display(),
62                prefix = self.context_path.to_string_lossy()
63            );
64            return false;
65        };
66
67        if relative_path.starts_with(".git") {
68            tracing::debug!(
69                "not ignoring {path} as it seems to be a git file",
70                path = path.as_ref().display()
71            );
72            return false;
73        }
74
75        if let Some(global) = &self.global {
76            if global
77                .matched_path_or_any_parents(relative_path, false)
78                .is_ignore()
79            {
80                tracing::debug!(
81                    "ignoring {path} as it is ignored by global gitignore",
82                    path = path.as_ref().display()
83                );
84                return true;
85            }
86        }
87
88        self.ignore
89            .matched_path_or_any_parents(relative_path, false)
90            .is_ignore()
91    }
92
93    fn iter(&self) -> impl Iterator<Item = Result<DirEntry, walkdir::Error>> {
94        WalkDir::new(&self.context_path).into_iter()
95    }
96
97    pub async fn build_tar(&self) -> Result<ContextArchive, ContextError> {
98        let buffer = Vec::new();
99
100        let mut tar = Builder::new(buffer);
101
102        // First lets add the actual dockerfile
103        let mut file = fs_err::tokio::File::open(&self.dockerfile).await?;
104        let mut buffer_content = Vec::new();
105        file.read_to_end(&mut buffer_content).await?;
106
107        // Prepare header for Dockerfile
108        let mut header = Header::new_gnu();
109        header.set_size(buffer_content.len() as u64);
110        header.set_mode(0o644);
111        header.set_cksum();
112
113        // Add Dockerfile to tar
114        tar.append_data(
115            &mut header,
116            self.dockerfile
117                .file_name()
118                .expect("Infallible; No file name"),
119            &*buffer_content,
120        )
121        .await?;
122
123        for entry in self.iter() {
124            let Ok(entry) = entry else {
125                tracing::warn!(?entry, "Failed to read entry");
126                continue;
127            };
128            let path = entry.path();
129
130            let Ok(relative_path) = path.strip_prefix(&self.context_path) else {
131                tracing::warn!(?path, "Failed to strip prefix on path");
132                continue;
133            };
134
135            if path.is_dir() && !path.is_symlink() {
136                tracing::debug!(path = ?path, relative_path = ?relative_path, "Adding directory to tar");
137                if let Err(err) = tar.append_path(relative_path).await {
138                    tracing::warn!(?err, "Failed to append path to tar");
139                }
140                continue;
141            }
142
143            if self.is_ignored(path) {
144                tracing::debug!(path = ?path, "Ignored file");
145                continue;
146            }
147
148            if path.is_symlink() {
149                tracing::debug!(path = ?path, "Adding symlink to tar");
150                let Ok(link_target) = tokio::fs::read_link(path).await else {
151                    continue;
152                }; // The target of the symlink
153                let Ok(metadata) = entry.metadata() else {
154                    continue;
155                };
156                tracing::debug!(link_target = ?link_target, "Symlink target");
157                let mut header = Header::new_gnu();
158
159                // Indicate it's a symlink
160                header.set_entry_type(EntryType::Symlink);
161                // The tar specification requires setting the link name for a symlink
162                if let Err(error) = header.set_link_name(&link_target) {
163                    tracing::warn!(?error, "Failed to set link name on {link_target:#?}");
164                    continue;
165                }
166
167                // Set ownership, permissions, etc.
168                header.set_uid(metadata.uid() as u64);
169                header.set_gid(metadata.gid() as u64);
170                // For a symlink, the "mode" is often ignored by many tools,
171                // but we’ll set it anyway to match the source:
172                header.set_mode(metadata.mode());
173                // Set modification time (use 0 or a real timestamp as you prefer)
174                header.set_mtime(metadata.mtime() as u64);
175                // Symlinks don’t store file data in the tar, so size is 0
176                header.set_size(0);
177
178                if let Err(error) = tar.append_data(&mut header, path, tokio::io::empty()).await {
179                    tracing::warn!(
180                        ?error,
181                        "Failed to append symlink to tar on {link_target:#?}"
182                    );
183                    continue;
184                }
185                continue;
186            }
187
188            tracing::debug!(path = ?path, "Adding file to tar");
189            let mut file = fs_err::tokio::File::open(path).await?;
190            let mut buffer_content = Vec::new();
191            file.read_to_end(&mut buffer_content).await?;
192
193            let mut header = Header::new_gnu();
194            header.set_size(buffer_content.len() as u64);
195            header.set_mode(0o644);
196            header.set_cksum();
197
198            let relative_path = path.strip_prefix(&self.context_path)?;
199            tar.append_data(&mut header, relative_path, &*buffer_content)
200                .await?;
201        }
202
203        let result = tar.into_inner().await?;
204
205        Ok(result)
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use std::fs;
213    use std::io::Write;
214    use tempfile::{tempdir, NamedTempFile};
215
216    #[test_log::test(tokio::test)]
217    async fn test_is_ignored() {
218        let dir = tempdir().unwrap();
219        let context_path = dir.path().to_path_buf();
220
221        // Create .gitignore file
222        let mut gitignore_file = fs::File::create(context_path.join(".gitignore")).unwrap();
223        writeln!(gitignore_file, "*.log").unwrap();
224
225        // Create .dockerignore file
226        let mut dockerignore_file = fs::File::create(context_path.join(".dockerignore")).unwrap();
227        writeln!(dockerignore_file, "*.tmp").unwrap();
228
229        dbg!(&std::fs::read_to_string(context_path.join(".gitignore")).unwrap());
230
231        let dockerfile = NamedTempFile::new().unwrap();
232
233        let context_builder = ContextBuilder::from_path(&context_path, dockerfile.path()).unwrap();
234
235        // Create test files
236        let log_file = context_path.join("test.log");
237        let tmp_file = context_path.join("test.tmp");
238        let txt_file = context_path.join("test.txt");
239
240        fs::File::create(&log_file).unwrap();
241        fs::File::create(&tmp_file).unwrap();
242        fs::File::create(&txt_file).unwrap();
243
244        assert!(context_builder.is_ignored(&log_file));
245        assert!(context_builder.is_ignored(&tmp_file));
246        assert!(!context_builder.is_ignored(&txt_file));
247    }
248
249    #[test_log::test(tokio::test)]
250    async fn test_adds_git_even_if_in_ignore() {
251        let dir = tempdir().unwrap();
252        let context_path = dir.path().to_path_buf();
253
254        // Create .gitignore file
255        let mut gitignore_file = fs::File::create(context_path.join(".gitignore")).unwrap();
256        writeln!(gitignore_file, ".git").unwrap();
257
258        let dockerfile = NamedTempFile::new().unwrap();
259        let context_builder = ContextBuilder::from_path(&context_path, dockerfile.path()).unwrap();
260
261        assert!(!context_builder.is_ignored(".git"));
262    }
263
264    #[test_log::test(tokio::test)]
265    async fn test_works_without_gitignore() {
266        let dir = tempdir().unwrap();
267        let context_path = dir.path().to_path_buf();
268
269        // Create .gitignore file
270
271        let dockerfile = NamedTempFile::new().unwrap();
272
273        let context_builder = ContextBuilder::from_path(&context_path, dockerfile.path()).unwrap();
274
275        assert!(!context_builder.is_ignored(".git"));
276        assert!(!context_builder.is_ignored("Dockerfile"));
277
278        fs::File::create(context_path.join("Dockerfile")).unwrap();
279
280        assert!(!context_builder.is_ignored("Dockerfile"));
281    }
282}