use std::collections::BTreeMap;
use std::env;
use std::ffi::OsString;
use std::fs::create_dir_all;
use std::fs::File;
use std::fs::OpenOptions;
use std::io;
use std::io::ErrorKind;
use std::net::SocketAddr;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::str::FromStr as _;
use anyhow::anyhow;
use anyhow::ensure;
use anyhow::Context as _;
use anyhow::Result;
use git2::Repository;
use git2::Signature;
use serde::Deserialize;
use serde::Serialize;
use serde_json::from_reader;
use serde_json::to_writer_pretty;
const GIT_USER: &str = "cargo-http-registry";
const GIT_EMAIL: &str = "cargo-http-registry@example.com";
fn parse_port(url: &str) -> Result<u16> {
let addr = url
.split('/')
.nth(2)
.ok_or_else(|| anyhow!("provided URL {} has unexpected format", url))?;
let addr =
SocketAddr::from_str(addr).with_context(|| format!("failed to parse address {}", addr))?;
Ok(addr.port())
}
fn symlink_dir<P, Q>(original: P, link: Q) -> io::Result<()>
where
P: AsRef<Path>,
Q: AsRef<Path>,
{
#[cfg(unix)]
use std::os::unix::fs::symlink;
#[cfg(windows)]
use std::os::windows::fs::symlink_dir as symlink;
symlink(original, link)
}
#[derive(Debug, Serialize)]
pub struct Dep {
pub name: String,
pub req: String,
pub features: Vec<String>,
pub optional: bool,
pub default_features: bool,
pub target: Option<String>,
pub kind: String,
pub registry: Option<String>,
pub package: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct Entry {
pub name: String,
pub vers: String,
pub deps: Vec<Dep>,
pub cksum: String,
pub features: BTreeMap<String, Vec<String>>,
pub yanked: bool,
pub links: Option<String>,
}
#[derive(Debug, Default, Deserialize, Serialize)]
struct Config {
dl: String,
api: Option<String>,
}
pub struct Index {
root: PathBuf,
git_user: String,
git_email: String,
repository: Repository,
}
impl Index {
pub fn new<P>(root: P, addr: &SocketAddr) -> Result<Self>
where
P: Into<PathBuf>,
{
fn inner(root: PathBuf, addr: &SocketAddr) -> Result<Index> {
let git_user = env::var_os("GIT_AUTHOR_NAME").unwrap_or_else(|| OsString::from(GIT_USER));
let git_user = git_user
.to_str()
.context("GIT_AUTHOR_NAME does not contain valid UTF-8")?
.to_string();
let git_email = env::var_os("GIT_AUTHOR_EMAIL").unwrap_or_else(|| OsString::from(GIT_EMAIL));
let git_email = git_email
.to_str()
.context("GIT_AUTHOR_EMAIL does not contain valid UTF-8")?
.to_string();
create_dir_all(&root)
.with_context(|| format!("failed to create directory {}", root.display()))?;
let repository = Repository::init(&root)
.with_context(|| format!("failed to initialize git repository {}", root.display()))?;
let mut index = Index {
root,
git_user,
git_email,
repository,
};
index.ensure_has_commit()?;
index.ensure_config(addr)?;
index.ensure_index_symlink()?;
index.update_server_info()?;
Ok(index)
}
let root = root.into();
inner(root, addr)
}
pub fn add(&mut self, relative_file_path: &Path) -> Result<()> {
let mut index = self
.repository
.index()
.context("failed to retrieve git repository index")?;
index
.add_path(relative_file_path)
.context("failed to add file to git index")?;
index
.write()
.context("failed to write git repository index")?;
Ok(())
}
pub fn commit(&mut self, message: &str) -> Result<()> {
let mut index = self
.repository
.index()
.context("failed to retrieve git repository index object")?;
let tree_id = index
.write_tree()
.context("failed to write git repository index tree")?;
let tree = self
.repository
.find_tree(tree_id)
.context("failed to find tree object in git repository")?;
let empty = self
.repository
.is_empty()
.context("unable to check git repository empty status")?;
let signature = Signature::now(&self.git_user, &self.git_email)
.context("failed to create git signature object")?;
if empty {
self
.repository
.commit(Some("HEAD"), &signature, &signature, message, &tree, &[])
} else {
let oid = self
.repository
.refname_to_id("HEAD")
.context("failed to map HEAD to git id")?;
let parent = self
.repository
.find_commit(oid)
.context("failed to find HEAD commit")?;
self.repository.commit(
Some("HEAD"),
&signature,
&signature,
message,
&tree,
&[&parent],
)
}
.context("failed to create git commit")?;
self.update_server_info()?;
Ok(())
}
fn update_server_info(&self) -> Result<()> {
let status = Command::new("git")
.current_dir(&self.root)
.arg("update-server-info")
.status()
.context("failed to run git update-server-info")?;
ensure!(status.success(), "git update-server-info failed");
Ok(())
}
pub fn try_read_port(root: &Path) -> Result<u16> {
let config = root.join("config.json");
let file = File::open(config).context("failed to open config.json")?;
let config = from_reader::<_, Config>(&file).context("failed to parse config.json")?;
config
.api
.ok_or_else(|| anyhow!("no API URL present in config"))
.and_then(|api| parse_port(&api))
}
fn ensure_has_commit(&mut self) -> Result<()> {
let empty = self
.repository
.is_empty()
.context("unable to check git repository empty status")?;
if empty {
self
.commit("Create new repository for cargo registry")
.context("failed to create initial git commit")?;
}
Ok(())
}
fn ensure_config(&mut self, addr: &SocketAddr) -> Result<()> {
let path = self.root.join("config.json");
let result = OpenOptions::new().read(true).write(true).open(&path);
match result {
Ok(file) => {
let mut config = from_reader::<_, Config>(&file).context("failed to parse config.json")?;
let dl = format!(
"http://{}/api/v1/crates/{{crate}}/{{version}}/download",
addr
);
let api = format!("http://{}", addr);
if config.dl != dl || config.api.as_ref() != Some(&api) {
config.dl = dl;
config.api = Some(api);
let file = OpenOptions::new()
.write(true)
.truncate(true)
.open(&path)
.context("failed to reopen config.json")?;
to_writer_pretty(&file, &config).context("failed to update config.json")?;
self
.add(Path::new("config.json"))
.context("failed to stage config.json file")?;
self
.commit("Update config.json")
.context("failed to commit config.json")?;
}
},
Err(err) if err.kind() == ErrorKind::NotFound => {
let file = File::create(&path).context("failed to create config.json")?;
let config = Config {
dl: format!(
"http://{}/api/v1/crates/{{crate}}/{{version}}/download",
addr
),
api: Some(format!("http://{}", addr)),
};
to_writer_pretty(&file, &config).context("failed to write config.json")?;
self
.add(Path::new("config.json"))
.context("failed to stage config.json file")?;
self
.commit("Add initial config.json")
.context("failed to commit config.json")?;
},
Err(err) => return Err(err).context("failed to open/create config.json"),
}
Ok(())
}
fn ensure_index_symlink(&mut self) -> Result<()> {
let result = symlink_dir(".", self.root.join("index"));
match result {
Ok(()) => {
self
.add(Path::new("index"))
.context("failed to stage index symlink")?;
self
.commit("Add index symlink")
.context("failed to commit index symlink")?;
},
Err(err) if err.kind() == ErrorKind::AlreadyExists => (),
result => result.with_context(|| {
format!(
"failed to create index/ symbolic link below {}",
self.root.display()
)
})?,
}
Ok(())
}
#[inline]
pub fn root(&self) -> &Path {
&self.root
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
use std::str::FromStr;
use git2::RepositoryState;
use git2::StatusOptions;
use git2::StatusShow;
use tempfile::tempdir;
use test_fork::fork;
#[test]
fn url_port_parsing() {
let port = parse_port("http://127.0.0.1:36527").unwrap();
assert_eq!(port, 36527);
let port = parse_port("https://192.168.0.254:1").unwrap();
assert_eq!(port, 1);
}
#[test]
fn empty_index_repository() {
let root = tempdir().unwrap();
let addr = SocketAddr::from_str("192.168.0.1:9999").unwrap();
let index = Index::new(root.as_ref(), &addr).unwrap();
assert_eq!(index.repository.state(), RepositoryState::Clean);
assert!(index.repository.head().is_ok());
let file = index.root.join("config.json");
let config = File::open(file).unwrap();
let config = from_reader::<_, Config>(&config).unwrap();
assert_eq!(
config.dl,
"http://192.168.0.1:9999/api/v1/crates/{crate}/{version}/download"
);
assert_eq!(config.api, Some("http://192.168.0.1:9999".to_string()));
}
#[test]
fn prepopulated_index_repository() {
let root = tempdir().unwrap();
let mut file = File::create(root.as_ref().join("config.json")).unwrap();
file.write_all(br#"{"dl":"foobar"}"#).unwrap();
let addr = SocketAddr::from_str("254.0.0.0:1").unwrap();
let index = Index::new(root.as_ref(), &addr).unwrap();
assert_eq!(index.repository.state(), RepositoryState::Clean);
assert!(index.repository.head().is_ok());
let file = index.root.join("config.json");
let config = File::open(file).unwrap();
let config = from_reader::<_, Config>(&config).unwrap();
assert_eq!(
config.dl,
"http://254.0.0.0:1/api/v1/crates/{crate}/{version}/download"
);
assert_eq!(config.api, Some("http://254.0.0.0:1".to_string()));
}
#[test]
fn recreate_index() {
let root = tempdir().unwrap();
let addr = "127.0.0.1:0".parse().unwrap();
{
let _index = Index::new(root.path(), &addr).unwrap();
}
{
let _index = Index::new(root.path(), &addr).unwrap();
}
}
#[test]
fn no_untracked_files() {
let root = tempdir().unwrap();
let addr = "127.0.0.1:0".parse().unwrap();
let index = Index::new(root.path(), &addr).unwrap();
assert_eq!(index.repository.state(), RepositoryState::Clean);
let mut options = StatusOptions::new();
options
.show(StatusShow::IndexAndWorkdir)
.include_untracked(true)
.include_ignored(true)
.include_unmodified(false);
let statuses = index.repository.statuses(Some(&mut options)).unwrap();
assert_eq!(statuses.len(), 0);
}
#[fork]
#[test]
fn index_root_relative_path() {
let base = tempdir().unwrap();
let () = env::set_current_dir(&base).unwrap();
let addr = "127.0.0.1:0".parse().unwrap();
for special_name in ["config.json", "index"] {
let relative_index_root = Path::new(special_name);
let () = create_dir_all(relative_index_root).unwrap();
let index = Index::new(relative_index_root, &addr).unwrap();
assert_eq!(index.repository.state(), RepositoryState::Clean);
}
}
}