//! ref: composer/src/Composer/Json/JsonFile.php use crate::io::io_interface; use crate::util::Silencer; use anyhow::Result; use indexmap::IndexMap; use shirabe_external_packages::composer::pcre::{CaptureKey, Preg}; use shirabe_external_packages::seld::json_lint::JsonParser; use shirabe_external_packages::seld::json_lint::ParsingException; use shirabe_php_shim::{ InvalidArgumentException, JSON_PRETTY_PRINT, JSON_UNESCAPED_SLASHES, JSON_UNESCAPED_UNICODE, PhpMixed, RuntimeException, UnexpectedValueException, dirname, file_exists, file_get_contents, file_put_contents, is_dir, is_file, json_decode, json_encode_ex, mkdir, realpath, str_contains, str_ends_with, str_repeat, strlen, strpos, usleep, }; use crate::downloader::TransportException; use crate::io::IOInterface; use crate::io::IOInterfaceImmutable; use crate::json::JsonValidationException; use crate::util::Filesystem; use crate::util::HttpDownloader; #[derive(Debug, Clone)] pub struct JsonEncodeOptions { pub unescaped_slashes: bool, pub pretty_print: bool, pub unescaped_unicode: bool, pub indent: String, } impl Default for JsonEncodeOptions { fn default() -> Self { Self { unescaped_slashes: true, pretty_print: true, unescaped_unicode: true, indent: JsonFile::INDENT_DEFAULT.to_string(), } } } impl JsonEncodeOptions { pub fn none() -> Self { Self { unescaped_slashes: false, pretty_print: false, unescaped_unicode: false, indent: JsonFile::INDENT_DEFAULT.to_string(), } } fn to_flags(&self) -> i64 { let mut flags = 0; if self.unescaped_slashes { flags |= JSON_UNESCAPED_SLASHES; } if self.pretty_print { flags |= JSON_PRETTY_PRINT; } if self.unescaped_unicode { flags |= JSON_UNESCAPED_UNICODE; } flags } } /// Reads/writes json files. #[derive(Debug)] pub struct JsonFile { /// @var string path: String, /// @var ?HttpDownloader http_downloader: Option>>, /// @var ?IOInterface io: Option>>, /// @var string indent: String, } impl JsonFile { pub const LAX_SCHEMA: i64 = 1; pub const STRICT_SCHEMA: i64 = 2; pub const AUTH_SCHEMA: i64 = 3; pub const LOCK_SCHEMA: i64 = 4; pub const INDENT_DEFAULT: &'static str = " "; /// build.rs copies the Composer schema files into a res/ directory next to the /// executable; this resolves that path via the running executable's location. /// /// TODO(phase-f): this on-disk layout is hard to distribute. Embed the schema with /// include_str! and extract it to a temporary file at runtime instead. pub fn composer_schema_path() -> std::path::PathBuf { Self::schema_res_path("composer-schema.json") } /// See composer_schema_path. pub fn lock_schema_path() -> std::path::PathBuf { Self::schema_res_path("composer-lock-schema.json") } fn schema_res_path(filename: &str) -> std::path::PathBuf { let exe = std::env::current_exe().expect("failed to resolve current executable path"); let dir = exe.parent().expect("executable has no parent directory"); dir.join("res").join(filename) } /// Initializes json file reader/parser. /// /// @param string $path path to a lockfile /// @param ?HttpDownloader $httpDownloader required for loading http/https json files /// @throws \InvalidArgumentException pub fn new( path: String, http_downloader: Option>>, io: Option>>, ) -> Result { if http_downloader.is_none() && Preg::is_match(r"{^https?://}i", &path) { return Err(InvalidArgumentException { message: "http urls require a HttpDownloader instance to be passed".to_string(), code: 0, } .into()); } Ok(Self { path, http_downloader, io, indent: Self::INDENT_DEFAULT.to_string(), }) } pub fn get_path(&self) -> &str { &self.path } /// Checks whether json file exists. pub fn exists(&self) -> bool { is_file(&self.path) } /// Reads json file. /// /// @throws ParsingException /// @throws \RuntimeException /// @return mixed pub fn read(&mut self) -> Result { let json: Option = match (|| -> Result> { if let Some(http_downloader) = &self.http_downloader { Ok(http_downloader .borrow_mut() .get(&self.path, indexmap::IndexMap::new())? .get_body() .map(|s| s.to_string())) } else { if !Filesystem::is_readable(&self.path) { return Err(RuntimeException { message: format!("The file \"{}\" is not readable.", self.path), code: 0, } .into()); } if let Some(io) = &self.io && io.is_debug() { let mut realpath_info = String::new(); if let Some(realpath) = realpath(&self.path) && realpath != self.path { realpath_info = format!(" ({})", realpath); } io.write_error3( &format!("Reading {}{}", self.path, realpath_info), true, io_interface::NORMAL, ); } Ok(file_get_contents(&self.path)) } })() { Ok(j) => j, Err(e) => { // TransportException keeps its message verbatim; any other exception is wrapped // with the "Could not read" prefix. if let Some(te) = e.downcast_ref::() { return Err(RuntimeException { message: te.message.clone(), code: 0, } .into()); } return Err(RuntimeException { message: format!("Could not read {}\n\n{}", self.path, e), code: 0, } .into()); } }; let json = match json { Some(j) => j, None => { return Err(RuntimeException { message: format!("Could not read {}", self.path), code: 0, } .into()); } }; self.indent = Self::detect_indenting(Some(&json)); Self::parse_json(Some(&json), Some(&self.path)) } pub fn write(&self, hash: PhpMixed) -> Result<()> { self.write_with_options(hash, JsonEncodeOptions::default()) } pub fn write_with_options(&self, hash: PhpMixed, options: JsonEncodeOptions) -> Result<()> { if self.path == "php://memory" { file_put_contents( &self.path, Self::encode_with_options(&hash, options.clone()).as_bytes(), ); return Ok(()); } let dir = dirname(&self.path); if !is_dir(&dir) { if file_exists(&dir) { return Err(UnexpectedValueException { message: format!( "{} exists and is not a directory.", realpath(&dir).unwrap_or_default(), ), code: 0, } .into()); } // PHP: @mkdir($dir, 0777, true) if !Silencer::call(|| Ok(mkdir(&dir, 0o777, true))).unwrap_or(false) { return Err(UnexpectedValueException { message: format!("{} does not exist and could not be created.", dir), code: 0, } .into()); } } let mut retries = 3; while retries > 0 { retries -= 1; let attempt: Result<()> = (|| -> Result<()> { self.file_put_contents_if_modified( &self.path, &format!( "{}{}", Self::encode_with_options(&hash, options.clone()), if options.pretty_print { "\n" } else { "" }, ), )?; Ok(()) })(); match attempt { Ok(_) => break, Err(e) => { if retries > 0 { usleep(500_000); continue; } return Err(e); } } } Ok(()) } /// Modify file properties only if content modified /// /// @return int|false fn file_put_contents_if_modified(&self, path: &str, content: &str) -> Result> { // PHP: @file_get_contents($path) let current_content = Silencer::call(|| Ok(file_get_contents(path))) .ok() .flatten(); if current_content.is_none() || current_content.as_deref() != Some(content) { return Ok(file_put_contents(path, content.as_bytes())); } Ok(Some(0)) } /// Validates the schema of the current json file according to composer-schema.json rules /// /// @param int $schema a JsonFile::*_SCHEMA constant /// @param string|null $schemaFile a path to the schema file /// @throws JsonValidationException /// @throws ParsingException /// @return true true on success /// /// @phpstan-param self::*_SCHEMA $schema pub fn validate_schema(&self, schema: i64, schema_file: Option<&str>) -> Result { if !Filesystem::is_readable(&self.path) { return Err(RuntimeException { message: format!("The file \"{}\" is not readable.", self.path), code: 0, } .into()); } let content = file_get_contents(&self.path).unwrap_or_default(); let data = json_decode(&content, false)?; if matches!(data, PhpMixed::Null) && content != "null" { Self::validate_syntax(&content, Some(&self.path))?; } Self::validate_json_schema(&self.path, &data, schema, schema_file) } /// Validates the schema of the current json file according to composer-schema.json rules /// /// @param mixed $data Decoded JSON data to validate /// @param int $schema a JsonFile::*_SCHEMA constant /// @param string|null $schemaFile a path to the schema file /// @throws JsonValidationException /// @return true true on success /// /// @phpstan-param self::*_SCHEMA $schema pub fn validate_json_schema( source: &str, data: &PhpMixed, schema: i64, schema_file: Option<&str>, ) -> Result { let mut is_composer_schema_file = false; let schema_file = match schema_file { Some(f) => f.into(), None => { if schema == Self::LOCK_SCHEMA { Self::lock_schema_path() } else { is_composer_schema_file = true; Self::composer_schema_path() } } }; let mut schema_file = schema_file.to_string_lossy().into_owned(); // Prepend with file:// only when not using a special schema already (e.g. in the phar) if strpos(&schema_file, "://").is_none() { schema_file = format!("file://{}", schema_file); } // PHP: $schemaData = (object) ['$ref' => $schemaFile, '$schema' => "https://json-schema.org/draft-04/schema#"]; // A string-keyed `PhpMixed::Array` serializes as a JSON object, matching the (object) cast. let mut schema_data: PhpMixed = { let mut m = indexmap::IndexMap::new(); m.insert("$ref".to_string(), PhpMixed::String(schema_file.clone())); m.insert( "$schema".to_string(), PhpMixed::String("https://json-schema.org/draft-04/schema#".to_string()), ); PhpMixed::Array(m) }; if schema == Self::STRICT_SCHEMA && is_composer_schema_file { schema_data = json_decode(&file_get_contents(&schema_file).unwrap_or_default(), false)?; if let PhpMixed::Object(map) = &mut schema_data { map.insert("additionalProperties".to_string(), PhpMixed::Bool(false)); map.insert( "required".to_string(), PhpMixed::List(vec![ PhpMixed::String("name".to_string()), PhpMixed::String("description".to_string()), ]), ); } } else if schema == Self::AUTH_SCHEMA && is_composer_schema_file { let mut m = indexmap::IndexMap::new(); m.insert( "$ref".to_string(), PhpMixed::String(format!("{}#/properties/config", schema_file)), ); m.insert( "$schema".to_string(), PhpMixed::String("https://json-schema.org/draft-04/schema#".to_string()), ); schema_data = PhpMixed::Array(m); } // convert assoc arrays to objects let schema_value = serde_json::to_value(&schema_data)?; let data_value = serde_json::to_value(data)?; let validator = jsonschema::options() .with_retriever(FileRetriever) .build(&schema_value) .map_err(|e| anyhow::anyhow!("{e}"))?; let errors: Vec = validator .iter_errors(&data_value) .map(|error| { let mut property = error .instance_path() .as_str() .trim_start_matches('/') .replace('/', "."); // A missing required property is reported against its parent object, so the // instance path is the parent (empty at the root). Composer points the error at // the missing property itself, so append its name to match the `PROPERTY : MESSAGE` // shape. if let jsonschema::error::ValidationErrorKind::Required { property: missing } = error.kind() && let Some(name) = missing.as_str() { if property.is_empty() { property = name.to_string(); } else { property = format!("{}.{}", property, name); } } if property.is_empty() { error.to_string() } else { format!("{} : {}", property, error) } }) .collect(); if !errors.is_empty() { return Err(JsonValidationException::new( format!("\"{}\" does not match the expected JSON schema", source), errors, ) .into()); } Ok(true) } pub fn encode(data: &T) -> String { Self::encode_with_options(data, JsonEncodeOptions::default()) } pub fn encode_with_options( data: &T, options: JsonEncodeOptions, ) -> String { let json = json_encode_ex(data, options.to_flags()) .map_err(|err| RuntimeException { message: format!("JSON encoding failed: {}", err), code: 0, }) .unwrap(); // TODO(phase-c): propagating an Err. if options.pretty_print && options.indent != Self::INDENT_DEFAULT { // Pretty printing and not using default indentation let indent_owned = options.indent.clone(); return Preg::replace_callback( r"#^ {4,}#m", move |m: &indexmap::IndexMap< shirabe_external_packages::composer::pcre::CaptureKey, String, >| -> String { let whole = m .get(&shirabe_external_packages::composer::pcre::CaptureKey::ByIndex(0)) .map(|s| s.as_str()) .unwrap_or(""); str_repeat(&indent_owned, (strlen(whole) / 4) as usize) }, &json, ); } json } /// Parses json string and returns hash. /// /// @param null|string $json json string /// @param string $file the json file /// /// @throws ParsingException /// @return mixed pub fn parse_json(json: Option<&str>, file: Option<&str>) -> Result { let json = match json { None => return Ok(PhpMixed::Null), Some(j) => j, }; let mut data = json_decode(json, true)?; // PHP: `null === $data && JSON_ERROR_NONE !== json_last_error()`, i.e. the decode produced // null because of an error rather than because the input was the literal `null`. json_decode // here swallows the error into PhpMixed::Null, so detect the failure by comparing the source // against `null`, mirroring validateSchema's own `'null' !== $content` check. if matches!(data, PhpMixed::Null) && json != "null" { // attempt resolving simple conflicts in lock files so that one can run `composer update --lock` and get a valid lock file if let Some(file) = file && str_ends_with(file, ".lock") && str_contains(json, "\"content-hash\"") { let mut count: usize = 0; let replaced = Preg::replace5( r#"{\r?\n<<<<<<< [^\r\n]+\r?\n\s+"content-hash": *"[0-9a-f]+", *\r?\n(?:\|{7} [^\r\n]+\r?\n\s+"content-hash": *"[0-9a-f]+", *\r?\n)?=======\r?\n\s+"content-hash": *"[0-9a-f]+", *\r?\n>>>>>>> [^\r\n]+(\r?\n)}"#, " \"content-hash\": \"VCS merge conflict detected. Please run `composer update --lock`.\",$1", json, -1, &mut count, ); if count == 1 { data = json_decode(&replaced, true)?; if !matches!(data, PhpMixed::Null) { return Ok(data); } } } Self::validate_syntax(json, file)?; } Ok(data) } /// Validates the syntax of a JSON string /// /// @throws \UnexpectedValueException /// @throws ParsingException /// @return bool true on success pub(crate) fn validate_syntax(json: &str, file: Option<&str>) -> Result { let mut parser = JsonParser::new(); let result = parser.lint(json); if result.is_none() { // TODO(phase-c): Rust's &str is guaranteed as UTF-8, but PHP string is not. Change `json` // to &[u8] and check UTF-8 validity here. // if (defined('JSON_ERROR_UTF8') && JSON_ERROR_UTF8 === json_last_error()) { // if ($file === null) { // throw new \UnexpectedValueException('The input is not UTF-8, could not parse as JSON'); // } else { // throw new \UnexpectedValueException('"' . $file . '" is not UTF-8, could not parse as JSON'); // } // } return Ok(true); } let result = result.unwrap(); Err(match file { None => ParsingException::new( format!( "The input does not contain valid JSON\n{}", result.get_message() ), result.get_details().clone(), ), Some(f) => ParsingException::new( format!( "\"{}\" does not contain valid JSON\n{}", f, result.get_message() ), result.get_details().clone(), ), } .into()) } pub fn detect_indenting(json: Option<&str>) -> String { let mut m: IndexMap = IndexMap::new(); if Preg::is_match3(r##"#^([ \t]+)"#m"##, json.unwrap_or(""), Some(&mut m)) { return m.get(&CaptureKey::ByIndex(1)).cloned().unwrap_or_default(); } Self::INDENT_DEFAULT.to_string() } } #[derive(Debug)] struct FileRetriever; impl jsonschema::Retrieve for FileRetriever { fn retrieve( &self, uri: &jsonschema::Uri, ) -> Result> { match uri.scheme().as_str() { "file" => { let file = std::fs::File::open(uri.path().as_str())?; Ok(serde_json::from_reader(file)?) } scheme => Err(format!("Unknown scheme {scheme}").into()), } } }