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