Skip to main content

kcode_rust_library_repository/
lib.rs

1//! Immutable-generation storage for managed Rust library source.
2
3use std::error::Error as StdError;
4use std::fmt;
5use std::fs::{self, File as FsFile, OpenOptions};
6use std::io::{self, Write};
7use std::path::{Path, PathBuf};
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use kcode_rust_source::{File, Source, validate_name};
12
13const HEAD: &str = "HEAD";
14const LOCK: &str = ".lock";
15const GENERATIONS: &str = "generations";
16static UNIQUE: AtomicU64 = AtomicU64::new(0);
17
18/// An opened repository snapshot with a private generation identity.
19pub struct Repository {
20    path: PathBuf,
21    name: String,
22    identity: String,
23    source: Source,
24}
25
26/// A repository failure.
27pub struct Error(String);
28
29/// Result type returned by this crate.
30pub type Result<T> = std::result::Result<T, Error>;
31
32impl Error {
33    fn new(category: &str, message: impl fmt::Display) -> Self {
34        Self(format!("{category}: {message}"))
35    }
36
37    fn io(operation: &str, path: impl AsRef<Path>, source: io::Error) -> Self {
38        Self::new(
39            "io",
40            format!("{operation} at {}: {source}", path.as_ref().display()),
41        )
42    }
43
44    fn source(error: kcode_rust_source::Error) -> Self {
45        Self(error.to_string())
46    }
47}
48
49impl fmt::Display for Error {
50    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
51        formatter.write_str(&self.0)
52    }
53}
54
55impl fmt::Debug for Error {
56    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
57        formatter.debug_tuple("Error").field(&self.0).finish()
58    }
59}
60
61impl StdError for Error {}
62
63/// Creates a new current-layout repository containing `source`.
64pub fn create(root: impl AsRef<Path>, name: &str, source: &Source) -> Result<Repository> {
65    validate_name(name).map_err(Error::source)?;
66    require_source_name(name, source)?;
67    let root = root_path(root.as_ref(), true)?;
68    let _root_lock = lock(&root.join(".kcode-rust-libs-v2.lock"), true)?;
69    let path = root.join(name);
70    match fs::symlink_metadata(&path) {
71        Ok(_) => {
72            return Err(Error::new(
73                "already_exists",
74                format!("managed library {name:?} already exists"),
75            ));
76        }
77        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
78        Err(error) => return Err(Error::io("inspect library destination", &path, error)),
79    }
80
81    let staging = unique_directory(&root, &format!(".{name}.create"))?;
82    let result = (|| {
83        FsFile::create(staging.join(LOCK))
84            .map_err(|error| Error::io("create repository lock file", &staging, error))?;
85        let generations = staging.join(GENERATIONS);
86        fs::create_dir(&generations)
87            .map_err(|error| Error::io("create generations directory", &generations, error))?;
88        let identity = unique_id("g");
89        let generation = generations.join(&identity);
90        fs::create_dir(&generation)
91            .map_err(|error| Error::io("create initial generation", &generation, error))?;
92        materialize(&generation, source)?;
93        fs::write(staging.join(HEAD), format!("{identity}\n"))
94            .map_err(|error| Error::io("write initial repository head", &staging, error))?;
95        fs::rename(&staging, &path)
96            .map_err(|error| Error::io("commit new managed library", &path, error))?;
97        Ok(Repository {
98            path: path.clone(),
99            name: name.to_owned(),
100            identity,
101            source: source.clone(),
102        })
103    })();
104    if result.is_err() {
105        let _ = fs::remove_dir_all(&staging);
106    }
107    result
108}
109
110/// Opens an existing current-layout repository without migrating other layouts.
111pub fn open(root: impl AsRef<Path>, name: &str) -> Result<Repository> {
112    validate_name(name).map_err(Error::source)?;
113    let root = root_path(root.as_ref(), false)?;
114    let path = checked_repository(&root.join(name), name)?;
115    require_current_layout(&path)?;
116    let _lock = lock(&path.join(LOCK), false)?;
117    read_current(path, name)
118}
119
120/// Returns the current package version and root documentation.
121pub fn docs(root: impl AsRef<Path>, name: &str) -> Result<(String, String)> {
122    validate_name(name).map_err(Error::source)?;
123    let root = root_path(root.as_ref(), false)?;
124    let path = checked_repository(&root.join(name), name)?;
125    require_current_layout(&path)?;
126    let _lock = lock(&path.join(LOCK), false)?;
127    let identity = read_head(&path)?;
128    let generation = checked_generation(&path, &identity)?;
129    let manifest = read_regular_utf8(&generation.join("Cargo.toml"))?;
130    let documentation = read_regular_utf8(&generation.join("Documentation.md"))?;
131    let source = Source::validate(
132        &[
133            File {
134                path: "Cargo.toml".to_owned(),
135                contents: manifest,
136            },
137            File {
138                path: "Documentation.md".to_owned(),
139                contents: documentation.clone(),
140            },
141        ],
142        name,
143    )
144    .map_err(Error::source)?;
145    Ok((source.version().to_owned(), documentation))
146}
147
148impl Repository {
149    /// Returns the complete source opened or most recently replaced.
150    pub fn source(&self) -> &Source {
151        &self.source
152    }
153
154    /// Atomically replaces the complete source if this snapshot is still current.
155    pub fn replace(&mut self, source: &Source) -> Result<()> {
156        require_source_name(&self.name, source)?;
157        let _lock = lock(&self.path.join(LOCK), false)?;
158        let current = read_head(&self.path)?;
159        if current != self.identity {
160            return Err(Error::new(
161                "stale_snapshot",
162                "the managed library changed; reopen before writing",
163            ));
164        }
165
166        let generations = self.path.join(GENERATIONS);
167        checked_directory(&generations, "generations directory")?;
168        let identity = unique_id("g");
169        let staging = generations.join(format!(".{identity}.stage"));
170        fs::create_dir(&staging)
171            .map_err(|error| Error::io("create staged generation", &staging, error))?;
172        if let Err(error) = materialize(&staging, source) {
173            let _ = fs::remove_dir_all(&staging);
174            return Err(error);
175        }
176        let generation = generations.join(&identity);
177        if let Err(error) = fs::rename(&staging, &generation) {
178            let _ = fs::remove_dir_all(&staging);
179            return Err(Error::io("finish staged generation", &generation, error));
180        }
181        if let Err(error) = replace_head(&self.path, &identity) {
182            let _ = fs::remove_dir_all(&generation);
183            return Err(error);
184        }
185
186        let previous = std::mem::replace(&mut self.identity, identity);
187        self.source = source.clone();
188        let _ = fs::remove_dir_all(generations.join(previous));
189        Ok(())
190    }
191}
192
193fn require_source_name(name: &str, source: &Source) -> Result<()> {
194    if source.name() != name {
195        return Err(Error::new(
196            "invalid_metadata",
197            format!(
198                "source package name must be {name:?}, found {:?}",
199                source.name()
200            ),
201        ));
202    }
203    Ok(())
204}
205
206fn read_current(path: PathBuf, name: &str) -> Result<Repository> {
207    let identity = read_head(&path)?;
208    let generation = checked_generation(&path, &identity)?;
209    let files = read_source(&generation)?;
210    let source = Source::validate(&files, name).map_err(Error::source)?;
211    Ok(Repository {
212        path,
213        name: name.to_owned(),
214        identity,
215        source,
216    })
217}
218
219fn materialize(root: &Path, source: &Source) -> Result<()> {
220    for file in source.files() {
221        let destination = root.join(&file.path);
222        let parent = destination.parent().ok_or_else(|| {
223            Error::new(
224                "unsafe_path",
225                format!("source path has no parent: {:?}", file.path),
226            )
227        })?;
228        fs::create_dir_all(parent)
229            .map_err(|error| Error::io("create source parent", parent, error))?;
230        fs::write(&destination, file.contents.as_bytes())
231            .map_err(|error| Error::io("write source file", &destination, error))?;
232    }
233    Ok(())
234}
235
236fn read_source(root: &Path) -> Result<Vec<File>> {
237    let mut files = Vec::new();
238    walk_source(root, root, &mut files)?;
239    files.sort_by(|left, right| left.path.cmp(&right.path));
240    Ok(files)
241}
242
243fn walk_source(root: &Path, directory: &Path, files: &mut Vec<File>) -> Result<()> {
244    let mut entries = fs::read_dir(directory)
245        .map_err(|error| Error::io("read source directory", directory, error))?
246        .collect::<std::result::Result<Vec<_>, _>>()
247        .map_err(|error| Error::io("read source entry", directory, error))?;
248    entries.sort_by_key(|entry| entry.file_name());
249
250    for entry in entries {
251        let path = entry.path();
252        let metadata = fs::symlink_metadata(&path)
253            .map_err(|error| Error::io("inspect source entry", &path, error))?;
254        if metadata.file_type().is_symlink() {
255            return Err(Error::new(
256                "unsafe_source",
257                format!("source symlink is not allowed: {}", path.display()),
258            ));
259        }
260        if metadata.is_dir() {
261            walk_source(root, &path, files)?;
262        } else if metadata.is_file() {
263            let relative = path.strip_prefix(root).map_err(|_| {
264                Error::new("invalid_repository", "source entry escaped its generation")
265            })?;
266            let relative = relative.to_str().ok_or_else(|| {
267                Error::new(
268                    "unsafe_source",
269                    format!("non-UTF-8 source path: {}", path.display()),
270                )
271            })?;
272            let relative = relative.replace(std::path::MAIN_SEPARATOR, "/");
273            if relative == "Cargo.lock" {
274                continue;
275            }
276            let bytes =
277                fs::read(&path).map_err(|error| Error::io("read source file", &path, error))?;
278            let contents = String::from_utf8(bytes).map_err(|_| {
279                Error::new(
280                    "unsafe_source",
281                    format!("non-UTF-8 source file: {}", path.display()),
282                )
283            })?;
284            files.push(File {
285                path: relative,
286                contents,
287            });
288        } else {
289            return Err(Error::new(
290                "unsafe_source",
291                format!("special source entry is not allowed: {}", path.display()),
292            ));
293        }
294    }
295    Ok(())
296}
297
298fn require_current_layout(path: &Path) -> Result<()> {
299    let head = exists(&path.join(HEAD))?;
300    let lock_file = exists(&path.join(LOCK))?;
301    let generations = exists(&path.join(GENERATIONS))?;
302    if head && lock_file && generations {
303        Ok(())
304    } else {
305        Err(Error::new(
306            "invalid_repository",
307            "repository is flat or contains a partial generation layout",
308        ))
309    }
310}
311
312fn root_path(path: &Path, create: bool) -> Result<PathBuf> {
313    let absolute = if path.is_absolute() {
314        path.to_path_buf()
315    } else {
316        std::env::current_dir()
317            .map_err(|error| Error::io("read current directory", ".", error))?
318            .join(path)
319    };
320    if create {
321        fs::create_dir_all(&absolute)
322            .map_err(|error| Error::io("create managed-library root", &absolute, error))?;
323    }
324    checked_directory(&absolute, "managed-library root")?;
325    fs::canonicalize(&absolute)
326        .map_err(|error| Error::io("canonicalize managed-library root", &absolute, error))
327}
328
329fn checked_repository(path: &Path, name: &str) -> Result<PathBuf> {
330    match fs::symlink_metadata(path) {
331        Ok(metadata) if metadata.file_type().is_symlink() => Err(Error::new(
332            "unsafe_source",
333            format!("managed library {name:?} is a symlink"),
334        )),
335        Ok(metadata) if metadata.is_dir() => Ok(path.to_path_buf()),
336        Ok(_) => Err(Error::new(
337            "invalid_repository",
338            format!("managed library {name:?} is not a directory"),
339        )),
340        Err(error) if error.kind() == io::ErrorKind::NotFound => Err(Error::new(
341            "not_found",
342            format!("managed library {name:?} does not exist"),
343        )),
344        Err(error) => Err(Error::io("inspect managed library", path, error)),
345    }
346}
347
348fn checked_generation(repository: &Path, identity: &str) -> Result<PathBuf> {
349    let generations = repository.join(GENERATIONS);
350    checked_directory(&generations, "generations directory")?;
351    let generation = generations.join(identity);
352    checked_directory(&generation, "repository generation")?;
353    Ok(generation)
354}
355
356fn checked_directory(path: &Path, label: &str) -> Result<()> {
357    match fs::symlink_metadata(path) {
358        Ok(metadata) if metadata.file_type().is_symlink() => Err(Error::new(
359            "unsafe_source",
360            format!("{label} is a symlink: {}", path.display()),
361        )),
362        Ok(metadata) if metadata.is_dir() => Ok(()),
363        Ok(_) => Err(Error::new(
364            "invalid_repository",
365            format!("{label} is not a directory: {}", path.display()),
366        )),
367        Err(error) => Err(Error::io("inspect directory", path, error)),
368    }
369}
370
371fn read_head(repository: &Path) -> Result<String> {
372    let head = read_regular_utf8(&repository.join(HEAD))?;
373    let identity = head.trim();
374    if identity.is_empty()
375        || identity.len() > 96
376        || !identity
377            .bytes()
378            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
379    {
380        return Err(Error::new(
381            "invalid_repository",
382            "repository HEAD contains an invalid generation identity",
383        ));
384    }
385    Ok(identity.to_owned())
386}
387
388fn read_regular_utf8(path: &Path) -> Result<String> {
389    let metadata = fs::symlink_metadata(path)
390        .map_err(|error| Error::io("inspect required source file", path, error))?;
391    if metadata.file_type().is_symlink() || !metadata.is_file() {
392        return Err(Error::new(
393            "unsafe_source",
394            format!("required source is not a regular file: {}", path.display()),
395        ));
396    }
397    let bytes = fs::read(path).map_err(|error| Error::io("read source file", path, error))?;
398    String::from_utf8(bytes).map_err(|_| {
399        Error::new(
400            "unsafe_source",
401            format!("source file is not UTF-8: {}", path.display()),
402        )
403    })
404}
405
406fn replace_head(repository: &Path, identity: &str) -> Result<()> {
407    let temporary = repository.join(format!(".HEAD.{}.tmp", unique_id("h")));
408    let result = (|| {
409        let mut file = OpenOptions::new()
410            .write(true)
411            .create_new(true)
412            .open(&temporary)
413            .map_err(|error| Error::io("create temporary repository head", &temporary, error))?;
414        file.write_all(format!("{identity}\n").as_bytes())
415            .map_err(|error| Error::io("write temporary repository head", &temporary, error))?;
416        file.sync_all()
417            .map_err(|error| Error::io("sync temporary repository head", &temporary, error))?;
418        fs::rename(&temporary, repository.join(HEAD))
419            .map_err(|error| Error::io("replace repository head", repository, error))
420    })();
421    if result.is_err() {
422        let _ = fs::remove_file(&temporary);
423    }
424    result
425}
426
427fn lock(path: &Path, create: bool) -> Result<CallLock> {
428    match fs::symlink_metadata(path) {
429        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
430            return Err(Error::new(
431                "unsafe_source",
432                format!("lock path is not a regular file: {}", path.display()),
433            ));
434        }
435        Ok(_) => {}
436        Err(error) if create && error.kind() == io::ErrorKind::NotFound => {}
437        Err(error) => return Err(Error::io("inspect lock file", path, error)),
438    }
439    let mut options = OpenOptions::new();
440    options.read(true).write(true);
441    if create {
442        options.create(true).truncate(false);
443    }
444    let file = options
445        .open(path)
446        .map_err(|error| Error::io("open lock file", path, error))?;
447    if !file
448        .metadata()
449        .map_err(|error| Error::io("inspect opened lock file", path, error))?
450        .is_file()
451    {
452        return Err(Error::new(
453            "unsafe_source",
454            format!("opened lock path is not a regular file: {}", path.display()),
455        ));
456    }
457    FsFile::lock(&file).map_err(|error| Error::io("lock managed library", path, error))?;
458    Ok(CallLock(file))
459}
460
461struct CallLock(FsFile);
462
463impl Drop for CallLock {
464    fn drop(&mut self) {
465        let _ = FsFile::unlock(&self.0);
466    }
467}
468
469fn exists(path: &Path) -> Result<bool> {
470    match fs::symlink_metadata(path) {
471        Ok(_) => Ok(true),
472        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
473        Err(error) => Err(Error::io("inspect repository path", path, error)),
474    }
475}
476
477fn unique_directory(parent: &Path, prefix: &str) -> Result<PathBuf> {
478    loop {
479        let path = parent.join(format!("{prefix}-{}", unique_id("d")));
480        match fs::create_dir(&path) {
481            Ok(()) => return Ok(path),
482            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
483            Err(error) => return Err(Error::io("create unique directory", path, error)),
484        }
485    }
486}
487
488fn unique_id(prefix: &str) -> String {
489    let nanos = SystemTime::now()
490        .duration_since(UNIX_EPOCH)
491        .unwrap_or_default()
492        .as_nanos();
493    let counter = UNIQUE.fetch_add(1, Ordering::Relaxed);
494    format!("{prefix}-{:x}-{nanos:x}-{counter:x}", std::process::id())
495}
496
497#[cfg(test)]
498mod tests {
499    use std::fs;
500    use std::path::{Path, PathBuf};
501
502    use kcode_rust_source::{File, Source};
503
504    use super::{create, docs, open};
505
506    struct Root(PathBuf);
507
508    impl Root {
509        fn new(label: &str) -> Self {
510            let path = std::env::temp_dir().join(format!(
511                "kcode-rust-library-repository-{label}-{}-{}",
512                std::process::id(),
513                super::UNIQUE.fetch_add(1, super::Ordering::Relaxed)
514            ));
515            fs::create_dir(&path).unwrap();
516            Self(path)
517        }
518
519        fn path(&self) -> &Path {
520            &self.0
521        }
522    }
523
524    impl Drop for Root {
525        fn drop(&mut self) {
526            let _ = fs::remove_dir_all(&self.0);
527        }
528    }
529
530    fn source(documentation: &str) -> Source {
531        Source::validate(
532            &[
533                File {
534                    path: "Cargo.toml".to_owned(),
535                    contents:
536                        "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2024\"\n"
537                            .to_owned(),
538                },
539                File {
540                    path: "Documentation.md".to_owned(),
541                    contents: documentation.to_owned(),
542                },
543                File {
544                    path: "src/lib.rs".to_owned(),
545                    contents: String::new(),
546                },
547            ],
548            "demo",
549        )
550        .unwrap()
551    }
552
553    #[test]
554    fn current_repositories_replace_completely_and_fence_stale_snapshots() {
555        let root = Root::new("replace");
556        create(root.path(), "demo", &source("old")).unwrap();
557        let mut first = open(root.path(), "demo").unwrap();
558        let mut stale = open(root.path(), "demo").unwrap();
559        first.replace(&source("first")).unwrap();
560        let error = stale.replace(&source("second")).unwrap_err();
561        assert!(error.to_string().starts_with("stale_snapshot:"));
562        assert_eq!(docs(root.path(), "demo").unwrap().1, "first");
563    }
564
565    #[test]
566    fn flat_and_partial_layouts_fail_without_mutation() {
567        let root = Root::new("layouts");
568        let flat = root.path().join("flat");
569        fs::create_dir(&flat).unwrap();
570        fs::write(flat.join("Cargo.toml"), "untouched").unwrap();
571        assert!(open(root.path(), "flat").is_err());
572        assert_eq!(
573            fs::read_to_string(flat.join("Cargo.toml")).unwrap(),
574            "untouched"
575        );
576        assert!(!flat.join("HEAD").exists());
577        assert!(!flat.join(".lock").exists());
578
579        let partial = root.path().join("partial");
580        fs::create_dir(&partial).unwrap();
581        fs::write(partial.join(".lock"), "").unwrap();
582        assert!(open(root.path(), "partial").is_err());
583        assert!(!partial.join("HEAD").exists());
584        assert!(!partial.join("generations").exists());
585    }
586
587    #[cfg(unix)]
588    #[test]
589    fn generations_directory_symlinks_are_not_followed() {
590        use std::os::unix::fs::symlink;
591
592        let root = Root::new("generations-symlink");
593        create(root.path(), "demo", &source("docs")).unwrap();
594        let repository = root.path().join("demo");
595        let escaped = root.path().join("escaped-generations");
596        fs::rename(repository.join("generations"), &escaped).unwrap();
597        symlink(&escaped, repository.join("generations")).unwrap();
598
599        assert!(open(root.path(), "demo").is_err());
600        assert!(docs(root.path(), "demo").is_err());
601        assert_eq!(
602            fs::read_to_string(
603                escaped
604                    .join(fs::read_to_string(repository.join("HEAD")).unwrap().trim())
605                    .join("Documentation.md")
606            )
607            .unwrap(),
608            "docs"
609        );
610    }
611
612    #[cfg(unix)]
613    #[test]
614    fn docs_is_narrow_but_complete_open_rejects_source_symlinks() {
615        use std::os::unix::fs::symlink;
616
617        let root = Root::new("symlink");
618        create(root.path(), "demo", &source("docs")).unwrap();
619        let repository = root.path().join("demo");
620        let identity = fs::read_to_string(repository.join("HEAD")).unwrap();
621        let generation = repository.join("generations").join(identity.trim());
622        symlink(root.path(), generation.join("src/link")).unwrap();
623        assert_eq!(docs(root.path(), "demo").unwrap().1, "docs");
624        assert!(open(root.path(), "demo").is_err());
625    }
626}