1use std::collections::HashSet;
2use std::fmt;
3use std::fs;
4use std::io;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7
8use crate::files::{
9 absolute_lexical, load_files, paths_overlap, validate_rust_lib_name, validate_write_target,
10 write_file,
11};
12use crate::sandbox;
13use crate::{CheckResult, Error, Result, RustLibDocs, RustLibFile};
14
15const DOCUMENTATION_FILE: &str = "Documentation.md";
16const MANIFEST_FILE: &str = "Cargo.toml";
17const LEGACY_VERSION_FILE: &str = "Version.txt";
18
19#[derive(Clone)]
20struct RegistryToken(Arc<str>);
21
22impl RegistryToken {
23 fn new(value: impl Into<String>) -> Result<Self> {
24 let value = value.into();
25 let value = value.trim();
26 if value.is_empty() {
27 return Err(Error::InvalidRegistryToken);
28 }
29 Ok(Self(Arc::from(value)))
30 }
31
32 fn expose(&self) -> &str {
33 self.0.as_ref()
34 }
35}
36
37impl fmt::Debug for RegistryToken {
38 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39 f.write_str("RegistryToken([REDACTED])")
40 }
41}
42
43#[derive(Clone, Debug)]
45pub struct KcodeRustLibs {
46 rust_libs_root: PathBuf,
47 work_root: PathBuf,
48 registry_token: RegistryToken,
49}
50
51impl KcodeRustLibs {
52 pub fn new(
55 rust_libs_root: impl Into<PathBuf>,
56 crates_io_registry_token: impl Into<String>,
57 ) -> Result<Self> {
58 let registry_token = RegistryToken::new(crates_io_registry_token)?;
59 let rust_libs_lexical = absolute_lexical(&rust_libs_root.into())?;
60 let work_lexical = absolute_lexical(
61 &std::env::temp_dir().join(format!("kcode-rust-libs-work-{}", std::process::id())),
62 )?;
63 if paths_overlap(&rust_libs_lexical, &work_lexical) {
64 return Err(Error::RootsOverlap {
65 rust_libs_root: rust_libs_lexical,
66 work_root: work_lexical,
67 });
68 }
69
70 create_root(&rust_libs_lexical, "create Rust libraries root")?;
71 create_root(&work_lexical, "create work root")?;
72
73 let rust_libs_root = fs::canonicalize(&rust_libs_lexical).map_err(|source| {
74 Error::io(
75 "canonicalize Rust libraries root",
76 &rust_libs_lexical,
77 source,
78 )
79 })?;
80 let work_root = fs::canonicalize(&work_lexical)
81 .map_err(|source| Error::io("canonicalize work root", &work_lexical, source))?;
82
83 if paths_overlap(&rust_libs_root, &work_root) {
84 return Err(Error::RootsOverlap {
85 rust_libs_root,
86 work_root,
87 });
88 }
89
90 Ok(Self {
91 rust_libs_root,
92 work_root,
93 registry_token,
94 })
95 }
96
97 pub fn create_rust_lib(&self, name: &str) -> Result<OpenedRustLib> {
99 validate_rust_lib_name(name)?;
100 let root = self.rust_libs_root.join(name);
101
102 match fs::create_dir(&root) {
103 Ok(()) => {}
104 Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {
105 return Err(Error::RustLibAlreadyExists(name.to_owned()));
106 }
107 Err(source) => return Err(Error::io("create Rust library", root, source)),
108 }
109
110 let created = (|| {
111 let source_directory = root.join("src");
112 fs::create_dir(&source_directory).map_err(|source| {
113 Error::io(
114 "create Rust library source directory",
115 &source_directory,
116 source,
117 )
118 })?;
119
120 let manifest = format!(
121 "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\n"
122 );
123 write_new_file(&root.join(MANIFEST_FILE), &manifest, "write Cargo manifest")?;
124 write_new_file(
125 &root.join(DOCUMENTATION_FILE),
126 "",
127 "write Rust library documentation",
128 )?;
129 write_new_file(&source_directory.join("lib.rs"), "", "write library source")?;
130 Ok(())
131 })();
132
133 if let Err(error) = created {
134 let _ = fs::remove_dir_all(&root);
135 return Err(error);
136 }
137
138 self.open_validated(name, root)
139 }
140
141 pub fn open_rust_lib(&self, name: &str) -> Result<OpenedRustLib> {
143 validate_rust_lib_name(name)?;
144 let root = self.rust_libs_root.join(name);
145 let metadata = match fs::symlink_metadata(&root) {
146 Ok(metadata) => metadata,
147 Err(source) if source.kind() == io::ErrorKind::NotFound => {
148 return Err(Error::RustLibNotFound(name.to_owned()));
149 }
150 Err(source) => return Err(Error::io("inspect Rust library", root, source)),
151 };
152
153 if metadata.file_type().is_symlink() {
154 return Err(Error::SymlinkNotAllowed(root));
155 }
156 if !metadata.is_dir() {
157 return Err(Error::RustLibIsNotDirectory(name.to_owned()));
158 }
159
160 self.open_validated(name, root)
161 }
162
163 fn open_validated(&self, name: &str, root: PathBuf) -> Result<OpenedRustLib> {
164 let mut files = load_files(&root)?;
165 let mut docs = validate_rust_lib_metadata(&files)?;
166
167 if remove_legacy_version_file(&root, &files)? {
168 files = load_files(&root)?;
169 docs = validate_rust_lib_metadata(&files)?;
170 }
171
172 Ok(OpenedRustLib {
173 name: name.to_owned(),
174 root,
175 work_root: self.work_root.clone(),
176 registry_token: self.registry_token.clone(),
177 files,
178 docs,
179 })
180 }
181}
182
183fn create_root(path: &Path, action: &'static str) -> Result<()> {
184 fs::create_dir_all(path).map_err(|source| Error::io(action, path, source))?;
185 let metadata = fs::metadata(path).map_err(|source| Error::io("inspect root", path, source))?;
186 if !metadata.is_dir() {
187 return Err(Error::io(
188 "use root as a directory",
189 path,
190 io::Error::new(io::ErrorKind::NotADirectory, "path is not a directory"),
191 ));
192 }
193 Ok(())
194}
195
196fn write_new_file(path: &Path, contents: &str, action: &'static str) -> Result<()> {
197 fs::write(path, contents).map_err(|source| Error::io(action, path, source))
198}
199
200fn remove_legacy_version_file(root: &Path, files: &[RustLibFile]) -> Result<bool> {
201 if !files
202 .iter()
203 .any(|file| file.path.as_str() == LEGACY_VERSION_FILE)
204 {
205 return Ok(false);
206 }
207
208 let path = root.join(LEGACY_VERSION_FILE);
209 let metadata = fs::symlink_metadata(&path)
210 .map_err(|source| Error::io("inspect legacy version file", &path, source))?;
211 if metadata.file_type().is_symlink() {
212 return Err(Error::SymlinkNotAllowed(path));
213 }
214 if !metadata.is_file() {
215 return Err(Error::UnsupportedFileType(path));
216 }
217
218 fs::remove_file(&path)
219 .map_err(|source| Error::io("remove legacy version file", &path, source))?;
220 Ok(true)
221}
222
223#[derive(Debug)]
225pub struct OpenedRustLib {
226 name: String,
227 root: PathBuf,
228 work_root: PathBuf,
229 registry_token: RegistryToken,
230 files: Vec<RustLibFile>,
231 docs: RustLibDocs,
232}
233
234impl OpenedRustLib {
235 pub fn name(&self) -> &str {
236 &self.name
237 }
238
239 pub fn files(&self) -> &[RustLibFile] {
240 &self.files
241 }
242
243 pub fn docs(&self) -> &RustLibDocs {
245 &self.docs
246 }
247
248 pub fn write(&mut self, files: &[RustLibFile]) -> Result<()> {
250 let mut unique_paths = HashSet::with_capacity(files.len());
251 for file in files {
252 if !unique_paths.insert(file.path.clone()) {
253 return Err(Error::DuplicateWritePath(file.path.to_string()));
254 }
255 validate_write_target(&self.root, &file.path)?;
256 }
257
258 let current_files = load_files(&self.root)?;
259 validate_projected_metadata(¤t_files, files)?;
260
261 for file in files {
262 write_file(&self.root, file)?;
263 }
264
265 let reloaded = load_files(&self.root)?;
266 let docs = validate_rust_lib_metadata(&reloaded)?;
267 self.files = reloaded;
268 self.docs = docs;
269 Ok(())
270 }
271
272 pub fn check(&self) -> Result<CheckResult> {
274 sandbox::check(&self.root, &self.work_root)
275 }
276
277 pub fn publish(&self) -> Result<()> {
280 let check = self.check()?;
281 if !check.passed() {
282 return Err(Error::CheckFailed(check));
283 }
284
285 sandbox::publish(&self.root, &self.work_root, self.registry_token.expose())
286 }
287}
288
289fn validate_projected_metadata(
290 current: &[RustLibFile],
291 writes: &[RustLibFile],
292) -> Result<RustLibDocs> {
293 let projected = |path: &str| {
294 writes
295 .iter()
296 .find(|file| file.path.as_str() == path)
297 .or_else(|| current.iter().find(|file| file.path.as_str() == path))
298 .map(|file| file.contents.as_str())
299 };
300 validate_metadata_text(projected(DOCUMENTATION_FILE), projected(MANIFEST_FILE))
301}
302
303fn validate_rust_lib_metadata(files: &[RustLibFile]) -> Result<RustLibDocs> {
304 let contents = |path: &str| {
305 files
306 .iter()
307 .find(|file| file.path.as_str() == path)
308 .map(|file| file.contents.as_str())
309 };
310 validate_metadata_text(contents(DOCUMENTATION_FILE), contents(MANIFEST_FILE))
311}
312
313fn validate_metadata_text(
314 documentation: Option<&str>,
315 manifest: Option<&str>,
316) -> Result<RustLibDocs> {
317 let documentation = documentation.ok_or(Error::MissingRequiredFile(DOCUMENTATION_FILE))?;
318 let manifest = manifest.ok_or(Error::MissingRequiredFile(MANIFEST_FILE))?;
319 let version = parse_version(&manifest_package_version(manifest)?)?;
320 Ok(RustLibDocs {
321 version,
322 documentation: documentation.to_owned(),
323 })
324}
325
326fn parse_version(value: &str) -> Result<String> {
327 let components = value.split('.').collect::<Vec<_>>();
328 let valid_component = |component: &str| {
329 !component.is_empty()
330 && component.bytes().all(|byte| byte.is_ascii_digit())
331 && (component == "0" || !component.starts_with('0'))
332 };
333
334 if components.len() != 3 || !components.into_iter().all(valid_component) {
335 return Err(Error::InvalidVersion(value.to_owned()));
336 }
337
338 Ok(value.to_owned())
339}
340
341fn manifest_package_version(manifest: &str) -> Result<String> {
342 let mut in_package = false;
343 let mut version = None;
344
345 for raw_line in manifest.lines() {
346 let line = strip_toml_comment(raw_line).trim();
347 if line.is_empty() {
348 continue;
349 }
350 if line.starts_with('[') {
351 in_package = line
352 .strip_prefix('[')
353 .and_then(|value| value.strip_suffix(']'))
354 .is_some_and(|value| value.trim() == "package");
355 continue;
356 }
357 if !in_package {
358 continue;
359 }
360
361 let Some((key, value)) = line.split_once('=') else {
362 continue;
363 };
364 if key.trim() != "version" {
365 continue;
366 }
367 if version.is_some() {
368 return Err(Error::InvalidCargoManifest(
369 "[package] declares version more than once".to_owned(),
370 ));
371 }
372
373 let value = value.trim();
374 let unquoted = value
375 .strip_prefix('"')
376 .and_then(|value| value.strip_suffix('"'))
377 .or_else(|| {
378 value
379 .strip_prefix('\'')
380 .and_then(|value| value.strip_suffix('\''))
381 })
382 .filter(|value| !value.contains(['"', '\'', '\\']))
383 .ok_or_else(|| {
384 Error::InvalidCargoManifest(
385 "[package].version must be a single-line literal string".to_owned(),
386 )
387 })?;
388 version = Some(unquoted.to_owned());
389 }
390
391 version.ok_or_else(|| {
392 Error::InvalidCargoManifest(
393 "the root manifest needs a literal version in [package]".to_owned(),
394 )
395 })
396}
397
398fn strip_toml_comment(line: &str) -> &str {
399 let mut quote = None;
400 let mut escaped = false;
401 for (index, character) in line.char_indices() {
402 match quote {
403 Some('"') if escaped => escaped = false,
404 Some('"') if character == '\\' => escaped = true,
405 Some(active) if character == active => quote = None,
406 Some(_) => {}
407 None if character == '"' || character == '\'' => quote = Some(character),
408 None if character == '#' => return &line[..index],
409 None => {}
410 }
411 }
412 line
413}
414
415#[cfg(test)]
416mod tests {
417 use super::{parse_version, validate_rust_lib_metadata};
418 use crate::{Error, RustLibFile, RustLibPath};
419
420 fn file(path: &str, contents: &str) -> RustLibFile {
421 RustLibFile::new(RustLibPath::new(path).unwrap(), contents)
422 }
423
424 #[test]
425 fn accepts_only_canonical_stable_versions() {
426 for value in ["0.1.0", "12.34.56"] {
427 assert!(parse_version(value).is_ok(), "{value:?}");
428 }
429 for value in [
430 "v0.1.0",
431 "01.2.3",
432 "0.1",
433 "0.1.0-beta.1",
434 "0.1.0+build",
435 " 0.1.0 ",
436 ] {
437 assert!(
438 matches!(parse_version(value), Err(Error::InvalidVersion(_))),
439 "{value:?}"
440 );
441 }
442 }
443
444 #[test]
445 fn reads_the_canonical_version_from_the_root_manifest() {
446 let files = vec![
447 file(
448 "Cargo.toml",
449 "[package]\nname = \"demo#one\"\nversion = '2.0.0' # current\n",
450 ),
451 file("Documentation.md", "docs\n"),
452 ];
453 let docs = validate_rust_lib_metadata(&files).unwrap();
454 assert_eq!(docs.version, "2.0.0");
455 assert_eq!(docs.documentation, "docs\n");
456
457 let inherited = vec![
458 file(
459 "Cargo.toml",
460 "[package]\nname = \"demo\"\nversion.workspace = true\n",
461 ),
462 file("Documentation.md", ""),
463 ];
464 assert!(matches!(
465 validate_rust_lib_metadata(&inherited),
466 Err(Error::InvalidCargoManifest(_))
467 ));
468 }
469}