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 VERSION_FILE: &str = "Version.txt";
17const MANIFEST_FILE: &str = "Cargo.toml";
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(
130 &root.join(VERSION_FILE),
131 "0.1.0\n",
132 "write Rust library version",
133 )?;
134 write_new_file(&source_directory.join("lib.rs"), "", "write library source")?;
135 Ok(())
136 })();
137
138 if let Err(error) = created {
139 let _ = fs::remove_dir_all(&root);
140 return Err(error);
141 }
142
143 self.open_validated(name, root)
144 }
145
146 pub fn open_rust_lib(&self, name: &str) -> Result<OpenedRustLib> {
148 validate_rust_lib_name(name)?;
149 let root = self.rust_libs_root.join(name);
150 let metadata = match fs::symlink_metadata(&root) {
151 Ok(metadata) => metadata,
152 Err(source) if source.kind() == io::ErrorKind::NotFound => {
153 return Err(Error::RustLibNotFound(name.to_owned()));
154 }
155 Err(source) => return Err(Error::io("inspect Rust library", root, source)),
156 };
157
158 if metadata.file_type().is_symlink() {
159 return Err(Error::SymlinkNotAllowed(root));
160 }
161 if !metadata.is_dir() {
162 return Err(Error::RustLibIsNotDirectory(name.to_owned()));
163 }
164
165 self.open_validated(name, root)
166 }
167
168 fn open_validated(&self, name: &str, root: PathBuf) -> Result<OpenedRustLib> {
169 let files = load_files(&root)?;
170 let docs = validate_rust_lib_metadata(&files)?;
171 Ok(OpenedRustLib {
172 name: name.to_owned(),
173 root,
174 work_root: self.work_root.clone(),
175 registry_token: self.registry_token.clone(),
176 files,
177 docs,
178 })
179 }
180}
181
182fn create_root(path: &Path, action: &'static str) -> Result<()> {
183 fs::create_dir_all(path).map_err(|source| Error::io(action, path, source))?;
184 let metadata = fs::metadata(path).map_err(|source| Error::io("inspect root", path, source))?;
185 if !metadata.is_dir() {
186 return Err(Error::io(
187 "use root as a directory",
188 path,
189 io::Error::new(io::ErrorKind::NotADirectory, "path is not a directory"),
190 ));
191 }
192 Ok(())
193}
194
195fn write_new_file(path: &Path, contents: &str, action: &'static str) -> Result<()> {
196 fs::write(path, contents).map_err(|source| Error::io(action, path, source))
197}
198
199#[derive(Debug)]
201pub struct OpenedRustLib {
202 name: String,
203 root: PathBuf,
204 work_root: PathBuf,
205 registry_token: RegistryToken,
206 files: Vec<RustLibFile>,
207 docs: RustLibDocs,
208}
209
210impl OpenedRustLib {
211 pub fn name(&self) -> &str {
212 &self.name
213 }
214
215 pub fn files(&self) -> &[RustLibFile] {
216 &self.files
217 }
218
219 pub fn docs(&self) -> &RustLibDocs {
221 &self.docs
222 }
223
224 pub fn write(&mut self, files: &[RustLibFile]) -> Result<()> {
226 let mut unique_paths = HashSet::with_capacity(files.len());
227 for file in files {
228 if !unique_paths.insert(file.path.clone()) {
229 return Err(Error::DuplicateWritePath(file.path.to_string()));
230 }
231 validate_write_target(&self.root, &file.path)?;
232 }
233
234 let current_files = load_files(&self.root)?;
235 validate_projected_metadata(¤t_files, files)?;
236
237 for file in files {
238 write_file(&self.root, file)?;
239 }
240
241 let reloaded = load_files(&self.root)?;
242 let docs = validate_rust_lib_metadata(&reloaded)?;
243 self.files = reloaded;
244 self.docs = docs;
245 Ok(())
246 }
247
248 pub fn check(&self) -> Result<CheckResult> {
250 sandbox::check(&self.root, &self.work_root)
251 }
252
253 pub fn publish(&self) -> Result<()> {
256 validate_manifest_version(&self.files, &self.docs.version)?;
257
258 let check = self.check()?;
259 if !check.passed() {
260 return Err(Error::CheckFailed(check));
261 }
262
263 sandbox::publish(&self.root, &self.work_root, self.registry_token.expose())
264 }
265}
266
267fn validate_projected_metadata(
268 current: &[RustLibFile],
269 writes: &[RustLibFile],
270) -> Result<RustLibDocs> {
271 let projected = |path: &str| {
272 writes
273 .iter()
274 .find(|file| file.path.as_str() == path)
275 .or_else(|| current.iter().find(|file| file.path.as_str() == path))
276 .map(|file| file.contents.as_str())
277 };
278 validate_metadata_text(projected(DOCUMENTATION_FILE), projected(VERSION_FILE))
279}
280
281fn validate_rust_lib_metadata(files: &[RustLibFile]) -> Result<RustLibDocs> {
282 let contents = |path: &str| {
283 files
284 .iter()
285 .find(|file| file.path.as_str() == path)
286 .map(|file| file.contents.as_str())
287 };
288 validate_metadata_text(contents(DOCUMENTATION_FILE), contents(VERSION_FILE))
289}
290
291fn validate_metadata_text(
292 documentation: Option<&str>,
293 version: Option<&str>,
294) -> Result<RustLibDocs> {
295 let documentation = documentation.ok_or(Error::MissingRequiredFile(DOCUMENTATION_FILE))?;
296 let version = version.ok_or(Error::MissingRequiredFile(VERSION_FILE))?;
297 Ok(RustLibDocs {
298 version: parse_version(version)?,
299 documentation: documentation.to_owned(),
300 })
301}
302
303fn parse_version(contents: &str) -> Result<String> {
304 let value = contents.strip_suffix('\n').unwrap_or(contents);
305 let components = value.split('.').collect::<Vec<_>>();
306 let valid_component = |component: &str| {
307 !component.is_empty()
308 && component.bytes().all(|byte| byte.is_ascii_digit())
309 && (component == "0" || !component.starts_with('0'))
310 };
311
312 if components.len() != 3
313 || !components.into_iter().all(valid_component)
314 || (contents != value && contents != format!("{value}\n"))
315 {
316 return Err(Error::InvalidVersion(contents.to_owned()));
317 }
318
319 Ok(value.to_owned())
320}
321
322fn validate_manifest_version(files: &[RustLibFile], version: &str) -> Result<()> {
323 let manifest = files
324 .iter()
325 .find(|file| file.path.as_str() == MANIFEST_FILE)
326 .ok_or(Error::MissingRequiredFile(MANIFEST_FILE))?;
327 let manifest_version = manifest_package_version(&manifest.contents)?;
328
329 if manifest_version != version {
330 return Err(Error::VersionMismatch {
331 version_file: version.to_owned(),
332 cargo_manifest: manifest_version,
333 });
334 }
335 Ok(())
336}
337
338fn manifest_package_version(manifest: &str) -> Result<String> {
339 let mut in_package = false;
340 let mut version = None;
341
342 for raw_line in manifest.lines() {
343 let line = strip_toml_comment(raw_line).trim();
344 if line.is_empty() {
345 continue;
346 }
347 if line.starts_with('[') {
348 in_package = line
349 .strip_prefix('[')
350 .and_then(|value| value.strip_suffix(']'))
351 .is_some_and(|value| value.trim() == "package");
352 continue;
353 }
354 if !in_package {
355 continue;
356 }
357
358 let Some((key, value)) = line.split_once('=') else {
359 continue;
360 };
361 if key.trim() != "version" {
362 continue;
363 }
364 if version.is_some() {
365 return Err(Error::InvalidCargoManifest(
366 "[package] declares version more than once".to_owned(),
367 ));
368 }
369
370 let value = value.trim();
371 let unquoted = value
372 .strip_prefix('"')
373 .and_then(|value| value.strip_suffix('"'))
374 .or_else(|| {
375 value
376 .strip_prefix('\'')
377 .and_then(|value| value.strip_suffix('\''))
378 })
379 .filter(|value| !value.contains(['"', '\'', '\\']))
380 .ok_or_else(|| {
381 Error::InvalidCargoManifest(
382 "[package].version must be a single-line literal string".to_owned(),
383 )
384 })?;
385 version = Some(unquoted.to_owned());
386 }
387
388 version.ok_or_else(|| {
389 Error::InvalidCargoManifest(
390 "the root manifest needs a literal version in [package]".to_owned(),
391 )
392 })
393}
394
395fn strip_toml_comment(line: &str) -> &str {
396 let mut quote = None;
397 let mut escaped = false;
398 for (index, character) in line.char_indices() {
399 match quote {
400 Some('"') if escaped => escaped = false,
401 Some('"') if character == '\\' => escaped = true,
402 Some(active) if character == active => quote = None,
403 Some(_) => {}
404 None if character == '"' || character == '\'' => quote = Some(character),
405 None if character == '#' => return &line[..index],
406 None => {}
407 }
408 }
409 line
410}
411
412#[cfg(test)]
413mod tests {
414 use super::{parse_version, validate_manifest_version};
415 use crate::{Error, RustLibFile, RustLibPath};
416
417 fn file(path: &str, contents: &str) -> RustLibFile {
418 RustLibFile::new(RustLibPath::new(path).unwrap(), contents)
419 }
420
421 #[test]
422 fn accepts_only_canonical_stable_versions() {
423 for value in ["0.1.0", "0.1.0\n", "12.34.56"] {
424 assert!(parse_version(value).is_ok(), "{value:?}");
425 }
426 for value in [
427 "v0.1.0",
428 "01.2.3",
429 "0.1",
430 "0.1.0-beta.1",
431 "0.1.0+build",
432 "0.1.0\n\n",
433 " 0.1.0 ",
434 ] {
435 assert!(
436 matches!(parse_version(value), Err(Error::InvalidVersion(_))),
437 "{value:?}"
438 );
439 }
440 }
441
442 #[test]
443 fn compares_manifest_and_version_file_versions() {
444 let matching = vec![file(
445 "Cargo.toml",
446 "[package]\nname = \"demo\"\nversion = \"1.2.3\"\n",
447 )];
448 assert!(validate_manifest_version(&matching, "1.2.3").is_ok());
449 assert!(matches!(
450 validate_manifest_version(&matching, "1.2.4"),
451 Err(Error::VersionMismatch { .. })
452 ));
453
454 let commented = vec![file(
455 "Cargo.toml",
456 "[package]\nname = \"demo#one\"\nversion = '2.0.0' # current\n",
457 )];
458 assert!(validate_manifest_version(&commented, "2.0.0").is_ok());
459 }
460}