diff --git a/source/MaterialXFormat/File.cpp b/source/MaterialXFormat/File.cpp index 63ab6d2441..124f952ea0 100644 --- a/source/MaterialXFormat/File.cpp +++ b/source/MaterialXFormat/File.cpp @@ -17,6 +17,7 @@ #include #include #include + #include #endif #if defined(__linux__) @@ -32,6 +33,10 @@ #include #include +#if defined(_WIN32) + #include +#endif + MATERIALX_NAMESPACE_BEGIN const string VALID_SEPARATORS = "/\\"; @@ -49,6 +54,15 @@ const string PATH_LIST_SEPARATOR = ":"; #endif const string MATERIALX_SEARCH_PATH_ENV_VAR = "MATERIALX_SEARCH_PATH"; +const string TEMPORARY_DIRECTORY_PREFIX = "materialx_tmp_"; + +#if defined(_WIN32) +const int TEMPORARY_DIRECTORY_MAX_ATTEMPTS = 1000; +#else +const StringVec TEMPORARY_DIRECTORY_ENV_VARS = { "TMPDIR", "TMP", "TEMP", "TEMPDIR" }; +const string TEMPORARY_DIRECTORY_DEFAULT = "/tmp"; +#endif + inline bool hasWindowsDriveSpecifier(const string& val) { return (val.length() > 1 && std::isalpha((unsigned char) val[0]) && (val[1] == ':')); @@ -301,6 +315,77 @@ void FilePath::createDirectory(bool recursive) const #endif } +bool FilePath::removeDirectory(bool recursive) const +{ + if (recursive) + { + // Remove the contents of the directory, taking care not to follow symbolic + // links to locations outside of this directory. +#if defined(_WIN32) + WIN32_FIND_DATAA fd; + HANDLE hFind = FindFirstFileA((*this / "*").asString().c_str(), &fd); + if (hFind != INVALID_HANDLE_VALUE) + { + do + { + string entryName = fd.cFileName; + if (entryName == CURRENT_PATH_STRING || entryName == PARENT_PATH_STRING) + { + continue; + } + + FilePath entryPath = *this / entryName; + if ((fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) && + !(fd.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT)) + { + entryPath.removeDirectory(true); + } + else if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) + { + RemoveDirectoryA(entryPath.asString().c_str()); + } + else + { + DeleteFileA(entryPath.asString().c_str()); + } + } while (FindNextFileA(hFind, &fd)); + FindClose(hFind); + } +#else + DIR* dir = opendir(asString().c_str()); + if (dir) + { + while (struct dirent* entry = readdir(dir)) + { + string entryName = entry->d_name; + if (entryName == CURRENT_PATH_STRING || entryName == PARENT_PATH_STRING) + { + continue; + } + + FilePath entryPath = *this / entryName; + struct stat sb; + if (lstat(entryPath.asString().c_str(), &sb) == 0 && S_ISDIR(sb.st_mode)) + { + entryPath.removeDirectory(true); + } + else + { + unlink(entryPath.asString().c_str()); + } + } + closedir(dir); + } +#endif + } + +#if defined(_WIN32) + return (RemoveDirectoryA(asString().c_str()) != 0); +#else + return (rmdir(asString().c_str()) == 0); +#endif +} + bool FilePath::setCurrentPath() { #if defined(_WIN32) @@ -385,6 +470,75 @@ FilePath FilePath::getModulePath() #endif } +FilePath FilePath::createTemporaryDirectory(const FilePath& parentDir) +{ + FilePath tempParent = parentDir.isEmpty() ? getSystemTemporaryDirectory() : parentDir; + tempParent.createDirectory(true); + if (!tempParent.isDirectory()) + { + throw Exception("Error in createTemporaryDirectory: parent path is not a directory: " + tempParent.asString()); + } + +#if defined(_WIN32) + // Windows provides no atomic equivalent of mkdtemp, so generate candidate names + // until CreateDirectory succeeds. Because CreateDirectory fails rather than + // succeeding when the path already exists, no other process can be given the + // same directory. + std::random_device rd; + std::mt19937 gen(rd()); + std::uniform_int_distribution dis(100000, 999999); + + for (int attempt = 0; attempt < TEMPORARY_DIRECTORY_MAX_ATTEMPTS; attempt++) + { + FilePath tempDir = tempParent / (TEMPORARY_DIRECTORY_PREFIX + std::to_string(dis(gen))); + if (CreateDirectoryA(tempDir.asString().c_str(), nullptr)) + { + return tempDir; + } + if (GetLastError() != ERROR_ALREADY_EXISTS) + { + throw Exception("Error in createTemporaryDirectory: " + std::to_string(GetLastError())); + } + } + + throw Exception("Error in createTemporaryDirectory: no unique name found in " + tempParent.asString()); +#else + // Create the directory with mkdtemp, which generates a unique name, creates the + // directory atomically, and restricts access to the current user. + string nameTemplate = (tempParent / (TEMPORARY_DIRECTORY_PREFIX + "XXXXXX")).asString(); + vector buf(nameTemplate.begin(), nameTemplate.end()); + buf.push_back('\0'); + if (!mkdtemp(buf.data())) + { + throw Exception("Error in createTemporaryDirectory: " + string(strerror(errno))); + } + return FilePath(buf.data()); +#endif +} + +FilePath FilePath::getSystemTemporaryDirectory() +{ +#if defined(_WIN32) + std::array buf; + DWORD length = GetTempPathA((DWORD) buf.size(), buf.data()); + if (!length || length > buf.size()) + { + throw Exception("Error in getSystemTemporaryDirectory: " + std::to_string(GetLastError())); + } + return FilePath(string(buf.data(), length)); +#else + for (const string& envVar : TEMPORARY_DIRECTORY_ENV_VARS) + { + string tempDir = getEnviron(envVar); + if (!tempDir.empty()) + { + return FilePath(tempDir); + } + } + return FilePath(TEMPORARY_DIRECTORY_DEFAULT); +#endif +} + FileSearchPath getEnvironmentPath(const string& sep) { string searchPathEnv = getEnviron(MATERIALX_SEARCH_PATH_ENV_VAR); diff --git a/source/MaterialXFormat/File.h b/source/MaterialXFormat/File.h index dc31b2060a..f8eb31f99f 100644 --- a/source/MaterialXFormat/File.h +++ b/source/MaterialXFormat/File.h @@ -198,6 +198,13 @@ class MX_FORMAT_API FilePath /// If recursive is true, any missing parent directories will be created as well. void createDirectory(bool recursive = false) const; + /// Remove the directory on the file system at the given path. + /// @param recursive If true, the contents of the directory will be removed as + /// well; otherwise the directory is only removed if it is empty. Symbolic + /// links within the directory are removed without following their targets. + /// @return True if the directory was removed. + bool removeDirectory(bool recursive = false) const; + /// Set the current working directory of the file system. bool setCurrentPath(); @@ -209,6 +216,21 @@ class MX_FORMAT_API FilePath /// Return the directory containing the executable module. static FilePath getModulePath(); + /// Create a temporary directory with a unique name. + /// @param parentDir The parent directory for the temporary directory, which + /// will be created if it does not already exist. If empty, the system's + /// default temporary directory is used. + /// @return A FilePath to the created temporary directory, which is accessible + /// only to the current user on platforms that support this restriction. + /// @throws Exception if the temporary directory cannot be created. + static FilePath createTemporaryDirectory(const FilePath& parentDir = FilePath()); + + /// Return the system's default temporary directory. This method does not + /// create the directory, and its existence is not guaranteed. + /// @return A FilePath to the system temporary directory. + /// @throws Exception if the system temporary directory cannot be determined. + static FilePath getSystemTemporaryDirectory(); + private: StringVec _vec; Type _type; diff --git a/source/MaterialXFormat/XmlIo.cpp b/source/MaterialXFormat/XmlIo.cpp index 83e4721dc1..b54b8768fd 100644 --- a/source/MaterialXFormat/XmlIo.cpp +++ b/source/MaterialXFormat/XmlIo.cpp @@ -355,7 +355,15 @@ void writeToXmlStream(DocumentPtr doc, std::ostream& stream, const XmlWriteOptio void writeToXmlFile(DocumentPtr doc, const FilePath& filename, const XmlWriteOptions* writeOptions) { + if (writeOptions && writeOptions->createDirectories) + { + filename.getParentPath().createDirectory(true); + } std::ofstream ofs(filename.asString()); + if (!ofs) + { + throw ExceptionFileMissing("Failed to open file for writing: " + filename.asString()); + } writeToXmlStream(doc, ofs, writeOptions); } diff --git a/source/MaterialXFormat/XmlIo.h b/source/MaterialXFormat/XmlIo.h index 5c80ae8005..4fd1a2237a 100644 --- a/source/MaterialXFormat/XmlIo.h +++ b/source/MaterialXFormat/XmlIo.h @@ -73,6 +73,10 @@ class MX_FORMAT_API XmlWriteOptions /// If provided, this function will be used to exclude specific elements /// (those returning false) from the write operation. Defaults to nullptr. ElementPredicate elementPredicate; + + /// If true, any necessary directories will be created to write the + /// file. + bool createDirectories = false; }; /// @class ExceptionParseError @@ -170,6 +174,7 @@ MX_FORMAT_API void writeToXmlStream(DocumentPtr doc, std::ostream& stream, const /// @param writeOptions An optional pointer to an XmlWriteOptions object. /// If provided, then the given options will affect the behavior of the /// write function. Defaults to a null pointer. +/// @throws ExceptionFileMissing if the file cannot be opened for writing. MX_FORMAT_API void writeToXmlFile(DocumentPtr doc, const FilePath& filename, const XmlWriteOptions* writeOptions = nullptr); /// Write a Document as XML to a new string, returned by value. diff --git a/source/MaterialXGenOsl/LibsToOso.cpp b/source/MaterialXGenOsl/LibsToOso.cpp index aca0e7c6ca..9ae74b12a6 100644 --- a/source/MaterialXGenOsl/LibsToOso.cpp +++ b/source/MaterialXGenOsl/LibsToOso.cpp @@ -503,7 +503,20 @@ int main(int argc, char* const argv[]) { // We only write the MaterialX document containing the implementations out if requested. mx::FilePath implMtlxDocFilePath = outputMtlxPath / "genoslnetwork_impl.mtlx"; - mx::writeToXmlFile(implMtlxDoc, implMtlxDocFilePath); + try + { + mx::writeToXmlFile(implMtlxDoc, implMtlxDocFilePath); + } + // Catch any file writing related exceptions. + catch (mx::Exception& exc) + { + std::cerr << "Encountered an exception while writing the implementation document to the " + "following path: " + << implMtlxDocFilePath.asString() << std::endl; + std::cerr << exc.what() << std::endl; + + hasFailed = true; + } } // If something went wrong, return an appropriate error code. diff --git a/source/MaterialXGraphEditor/Graph.cpp b/source/MaterialXGraphEditor/Graph.cpp index 15ebba758b..3dd58ce5d4 100644 --- a/source/MaterialXGraphEditor/Graph.cpp +++ b/source/MaterialXGraphEditor/Graph.cpp @@ -5074,5 +5074,12 @@ void Graph::saveDocument(mx::FilePath filePath) mx::XmlWriteOptions writeOptions; writeOptions.elementPredicate = getElementPredicate(); - mx::writeToXmlFile(writeDoc, filePath, &writeOptions); + try + { + mx::writeToXmlFile(writeDoc, filePath, &writeOptions); + } + catch (mx::Exception& e) + { + std::cerr << "Failed to write file: " << filePath.asString() << ": \"" << std::string(e.what()) << "\"" << std::endl; + } } diff --git a/source/MaterialXTest/MaterialXFormat/File.cpp b/source/MaterialXTest/MaterialXFormat/File.cpp index 3f432e9d97..025a71b710 100644 --- a/source/MaterialXTest/MaterialXFormat/File.cpp +++ b/source/MaterialXTest/MaterialXFormat/File.cpp @@ -8,6 +8,15 @@ #include #include +#include + +#include + +#if !defined(_WIN32) + #include + #include +#endif + namespace mx = MaterialX; TEST_CASE("Syntactic operations", "[file]") @@ -205,3 +214,109 @@ TEST_CASE("Get all files in directory", "[file]") REQUIRE(std::find(results.begin(), results.end(), filename) != results.end()); } } + +TEST_CASE("System temporary directory", "[file]") +{ + mx::FilePath systemTempDir = mx::FilePath::getSystemTemporaryDirectory(); + + REQUIRE(!systemTempDir.isEmpty()); + REQUIRE(systemTempDir.exists()); + REQUIRE(systemTempDir.isDirectory()); +} + +TEST_CASE("Create temporary directory", "[file]") +{ + // Create a temporary directory within the system temporary directory. + mx::FilePath systemTempDir = mx::FilePath::getSystemTemporaryDirectory(); + mx::FilePath tempDir = mx::FilePath::createTemporaryDirectory(); + REQUIRE(tempDir.exists()); + REQUIRE(tempDir.isDirectory()); + REQUIRE(tempDir.getParentPath().getNormalized() == systemTempDir.getNormalized()); + + // Verify that each temporary directory is unique. + mx::FilePath secondTempDir = mx::FilePath::createTemporaryDirectory(); + REQUIRE(secondTempDir.isDirectory()); + REQUIRE(secondTempDir != tempDir); + + // Create a temporary directory within an explicit parent directory, which + // is itself created on demand. + mx::FilePath explicitParent = tempDir / "parent" / "subdirectory"; + REQUIRE(!explicitParent.exists()); + mx::FilePath childTempDir = mx::FilePath::createTemporaryDirectory(explicitParent); + REQUIRE(childTempDir.isDirectory()); + REQUIRE(childTempDir.getParentPath().getNormalized() == explicitParent.getNormalized()); + + // Verify that a parent path referring to an existing file is rejected. + mx::FilePath existingFile = tempDir / "file.txt"; + std::ofstream(existingFile.asString()) << "content"; + REQUIRE(existingFile.exists()); + REQUIRE(!existingFile.isDirectory()); + REQUIRE_THROWS_AS(mx::FilePath::createTemporaryDirectory(existingFile), mx::Exception); + +#if !defined(_WIN32) + // Verify that the temporary directory is accessible only to the current user. + struct stat sb; + REQUIRE(stat(tempDir.asString().c_str(), &sb) == 0); + REQUIRE((sb.st_mode & 07777) == 0700); + + // Verify that a failure to create the directory is reported as an exception, + // rather than returning a path that does not exist. The root user bypasses + // permission checks, so this case is only meaningful for other users. + if (geteuid() != 0) + { + mx::FilePath readOnlyParent = tempDir / "readOnly"; + readOnlyParent.createDirectory(); + REQUIRE(readOnlyParent.isDirectory()); + REQUIRE(chmod(readOnlyParent.asString().c_str(), 0500) == 0); + REQUIRE_THROWS_AS(mx::FilePath::createTemporaryDirectory(readOnlyParent), mx::Exception); + REQUIRE(chmod(readOnlyParent.asString().c_str(), 0700) == 0); + } +#endif + + REQUIRE(tempDir.removeDirectory(true)); + REQUIRE(!tempDir.exists()); + REQUIRE(secondTempDir.removeDirectory(true)); + REQUIRE(!secondTempDir.exists()); +} + +TEST_CASE("Remove directory", "[file]") +{ + mx::FilePath tempDir = mx::FilePath::createTemporaryDirectory(); + + // Verify that a non-recursive removal leaves a non-empty directory in place. + mx::FilePath nestedDir = tempDir / "nested" / "subdirectory"; + nestedDir.createDirectory(true); + std::ofstream((nestedDir / "file.txt").asString()) << "content"; + REQUIRE(!tempDir.removeDirectory()); + REQUIRE(tempDir.isDirectory()); + + // Verify that a non-recursive removal succeeds for an empty directory. + mx::FilePath emptyDir = tempDir / "empty"; + emptyDir.createDirectory(); + REQUIRE(emptyDir.removeDirectory()); + REQUIRE(!emptyDir.exists()); + + // Verify that a removal of a path that does not exist is reported as a failure. + REQUIRE(!emptyDir.removeDirectory()); + REQUIRE(!emptyDir.removeDirectory(true)); + +#if !defined(_WIN32) + // Verify that a symbolic link within the directory is removed without following + // it to its target. + mx::FilePath linkTarget = mx::FilePath::createTemporaryDirectory(); + std::ofstream((linkTarget / "preserved.txt").asString()) << "content"; + mx::FilePath link = nestedDir / "link"; + REQUIRE(symlink(linkTarget.asString().c_str(), link.asString().c_str()) == 0); + REQUIRE(link.isDirectory()); +#endif + + // Verify that a recursive removal clears the directory and its contents. + REQUIRE(tempDir.removeDirectory(true)); + REQUIRE(!tempDir.exists()); + +#if !defined(_WIN32) + REQUIRE(linkTarget.isDirectory()); + REQUIRE((linkTarget / "preserved.txt").exists()); + REQUIRE(linkTarget.removeDirectory(true)); +#endif +} diff --git a/source/MaterialXTest/MaterialXFormat/XmlIo.cpp b/source/MaterialXTest/MaterialXFormat/XmlIo.cpp index 36fb703d66..99335d1348 100644 --- a/source/MaterialXTest/MaterialXFormat/XmlIo.cpp +++ b/source/MaterialXTest/MaterialXFormat/XmlIo.cpp @@ -9,6 +9,11 @@ #include #include +#if !defined(_WIN32) + #include + #include +#endif + namespace mx = MaterialX; TEST_CASE("Load content", "[xmlio]") @@ -371,3 +376,54 @@ TEST_CASE("Locale region testing", "[xmlio]") // Restore the original locale. std::locale::global(origLocale); } + +TEST_CASE("Write with created directories", "[xmlio]") +{ + mx::DocumentPtr doc = mx::createDocument(); + mx::NodeGraphPtr nodeGraph = doc->addNodeGraph("nodegraph1"); + nodeGraph->addNode("image", "image1"); + + mx::FilePath tempDir = mx::FilePath::createTemporaryDirectory(); + mx::FilePath filename = tempDir / "new" / "nested" / "directory" / "document.mtlx"; + REQUIRE(!filename.getParentPath().exists()); + + // By default, no directories are created, and the write fails. + mx::XmlWriteOptions writeOptions; + REQUIRE_THROWS_AS(mx::writeToXmlFile(doc, filename, &writeOptions), mx::ExceptionFileMissing); + REQUIRE(!filename.exists()); + + // With the createDirectories option, the parent hierarchy is created. + writeOptions.createDirectories = true; + mx::writeToXmlFile(doc, filename, &writeOptions); + REQUIRE(filename.getParentPath().isDirectory()); + REQUIRE(filename.exists()); + + // Verify that the written document may be read back without loss. + mx::DocumentPtr writtenDoc = mx::createDocument(); + mx::readFromXmlFile(writtenDoc, filename); + REQUIRE(*writtenDoc == *doc); + + // Verify that writing to an existing directory remains supported. + mx::FilePath secondFilename = filename.getParentPath() / "document2.mtlx"; + mx::writeToXmlFile(doc, secondFilename, &writeOptions); + REQUIRE(secondFilename.exists()); + +#if !defined(_WIN32) + // Verify that a permission failure is reported as an exception, rather than + // silently writing nothing. The root user bypasses permission checks, so + // this case is only meaningful for other users. + if (geteuid() != 0) + { + mx::FilePath readOnlyDir = tempDir / "readOnly"; + readOnlyDir.createDirectory(); + REQUIRE(chmod(readOnlyDir.asString().c_str(), 0500) == 0); + mx::FilePath deniedFilename = readOnlyDir / "denied" / "document.mtlx"; + REQUIRE_THROWS_AS(mx::writeToXmlFile(doc, deniedFilename, &writeOptions), mx::ExceptionFileMissing); + REQUIRE(!deniedFilename.exists()); + REQUIRE(chmod(readOnlyDir.asString().c_str(), 0700) == 0); + } +#endif + + REQUIRE(tempDir.removeDirectory(true)); + REQUIRE(!tempDir.exists()); +} diff --git a/source/MaterialXView/Viewer.cpp b/source/MaterialXView/Viewer.cpp index 6f12292ef0..07888df056 100644 --- a/source/MaterialXView/Viewer.cpp +++ b/source/MaterialXView/Viewer.cpp @@ -424,7 +424,14 @@ void Viewer::loadEnvironmentLight() if (_saveGeneratedLights) { _imageHandler->saveImage("IndirectRadiance.hdr", envRadianceMap); - mx::writeToXmlFile(_lightRigDoc, "DirectLightRig.mtlx"); + try + { + mx::writeToXmlFile(_lightRigDoc, "DirectLightRig.mtlx"); + } + catch (std::exception& e) + { + new ng::MessageDialog(this, ng::MessageDialog::Type::Warning, "Cannot save direct light rig", e.what()); + } } } @@ -657,10 +664,17 @@ void Viewer::createSaveMaterialsInterface(ng::ref parent, const std::str mx::XmlWriteOptions writeOptions; writeOptions.elementPredicate = getElementPredicate(); - mx::writeToXmlFile(material->getDocument(), filename, &writeOptions); + try + { + mx::writeToXmlFile(material->getDocument(), filename, &writeOptions); - // Update material file name - _materialFilename = filename; + // Update material file name + _materialFilename = filename; + } + catch (std::exception& e) + { + new ng::MessageDialog(this, ng::MessageDialog::Type::Warning, "Cannot save material document", e.what()); + } } m_process_events = true; }); @@ -2023,9 +2037,16 @@ bool Viewer::keyboard_event(int key, int scancode, int action, int modifiers) mx::XmlWriteOptions writeOptions; writeOptions.elementPredicate = getElementPredicate(); - mx::writeToXmlFile(translatedDoc, translatedFilename, &writeOptions); + try + { + mx::writeToXmlFile(translatedDoc, translatedFilename, &writeOptions); - new ng::MessageDialog(this, ng::MessageDialog::Type::Information, "Saved translated material: ", translatedFilename); + new ng::MessageDialog(this, ng::MessageDialog::Type::Information, "Saved translated material: ", translatedFilename); + } + catch (std::exception& e) + { + new ng::MessageDialog(this, ng::MessageDialog::Type::Warning, "Cannot save translated material", e.what()); + } } return true; }