1use 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
18pub struct Repository {
20 path: PathBuf,
21 name: String,
22 identity: String,
23 source: Source,
24}
25
26pub struct Error(String);
28
29pub 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
63pub 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
110pub 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
120pub 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 pub fn source(&self) -> &Source {
151 &self.source
152 }
153
154 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}