XRootD
XrdHttpTapeApiStore.cc
Go to the documentation of this file.
1 /******************************************************************************/
2 /* */
3 /* X r d H t t p T a p e A p i S t o r e . c c */
4 /* */
5 /* (c) 2026 by the XRootD Collaboration */
6 /* */
7 /* This file is part of the XRootD software suite. */
8 /* */
9 /******************************************************************************/
10 
11 #include "XrdHttpTapeApiStore.hh"
12 
13 #include "XrdOuc/XrdOucUtils.hh"
14 
15 #include <cctype>
16 #include <chrono>
17 #include <fstream>
18 #include <set>
19 #include <system_error>
20 #include <uuid/uuid.h>
21 
22 namespace
23 {
24 using Json = nlohmann::json;
25 namespace fs = std::filesystem;
26 
27 XrdHttpTapeApiStore::Status Error(int code, const std::string &message)
28 {
29  return {code, message};
30 }
31 
32 bool IsWithin(const fs::path &root, const fs::path &path)
33 {
34  auto rootPart = root.begin();
35  auto pathPart = path.begin();
36  while(rootPart != root.end() && pathPart != path.end())
37  {
38  if(*rootPart != *pathPart) return false;
39  ++rootPart;
40  ++pathPart;
41  }
42  return rootPart == root.end();
43 }
44 
45 bool IsRegularFile(const fs::path &path, bool &regular)
46 {
47  std::error_code error;
48  const fs::file_status status = fs::status(path, error);
49  if(error == std::errc::no_such_file_or_directory)
50  {
51  regular = false;
52  return true;
53  }
54  if(error) return false;
55  regular = fs::is_regular_file(status);
56  return true;
57 }
58 }
59 
61  : m_root(std::move(root)),
62  m_archiveRoot(m_root / "archive"),
63  m_diskRoot(m_root / "disk"),
64  m_requestsRoot(m_root / "requests")
65 {
66 }
67 
69 {
70  std::error_code error;
71  fs::create_directories(m_archiveRoot, error);
72  if(error) return Error(500, "could not create the tape archive directory");
73  fs::create_directories(m_diskRoot, error);
74  if(error) return Error(500, "could not create the disk directory");
75  fs::create_directories(m_requestsRoot, error);
76  if(error) return Error(500, "could not create the request directory");
77 
78  m_root = fs::weakly_canonical(m_root, error);
79  if(error) return Error(500, "could not resolve the Tape API directory");
80  m_archiveRoot = m_root / "archive";
81  m_diskRoot = m_root / "disk";
82  m_requestsRoot = m_root / "requests";
83  return {};
84 }
85 
86 std::uint64_t XrdHttpTapeApiStore::Now()
87 {
88  const auto now = std::chrono::system_clock::now().time_since_epoch();
89  return static_cast<std::uint64_t>(
90  std::chrono::duration_cast<std::chrono::seconds>(now).count());
91 }
92 
93 std::string XrdHttpTapeApiStore::GenerateRequestId()
94 {
95  uuid_t value;
96  uuid_generate_random(value);
97  char text[37] = {};
98  uuid_unparse_lower(value, text);
99  return text;
100 }
101 
102 bool XrdHttpTapeApiStore::IsRequestId(const std::string &requestId)
103 {
104  if(requestId.size() != 36) return false;
105  for(std::size_t index = 0; index < requestId.size(); ++index)
106  {
107  if(index == 8 || index == 13 || index == 18 || index == 23)
108  {
109  if(requestId[index] != '-') return false;
110  }
111  else if(!std::isxdigit(static_cast<unsigned char>(requestId[index])))
112  {
113  return false;
114  }
115  }
116  return true;
117 }
118 
119 XrdHttpTapeApiStore::Status XrdHttpTapeApiStore::ResolvePath(
120  const fs::path &root, const std::string &path, std::string &normalized,
121  fs::path &resolved) const
122 {
123  normalized = XrdOucUtils::NormalizePath(path);
124  if(normalized.empty() || normalized.front() != '/')
125  {
126  return Error(400, "file paths must be absolute");
127  }
128 
129  const fs::path relative = fs::path(normalized).relative_path();
130  for(const auto &part : relative)
131  {
132  if(part == "." || part == "..")
133  {
134  return Error(400, "file paths must not contain traversal components");
135  }
136  }
137 
138  std::error_code error;
139  resolved = fs::weakly_canonical(root / relative, error);
140  if(error || !IsWithin(root, resolved))
141  {
142  return Error(400, "file path is outside the configured storage root");
143  }
144  return {};
145 }
146 
147 XrdHttpTapeApiStore::Status XrdHttpTapeApiStore::WriteRequest(
148  const Json &request) const
149 {
150  const std::string requestId = request.at("id").get<std::string>();
151  const fs::path destination = m_requestsRoot / (requestId + ".json");
152  const fs::path temporary = m_requestsRoot / (requestId + ".json.tmp");
153 
154  std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
155  if(!output) return Error(500, "could not write the stage request");
156  output << request.dump();
157  output.close();
158  if(!output)
159  {
160  std::error_code ignored;
161  fs::remove(temporary, ignored);
162  return Error(500, "could not write the stage request");
163  }
164 
165  std::error_code error;
166  fs::rename(temporary, destination, error);
167  if(error)
168  {
169  fs::remove(temporary, error);
170  return Error(500, "could not persist the stage request");
171  }
172  return {};
173 }
174 
175 XrdHttpTapeApiStore::Status XrdHttpTapeApiStore::ReadRequest(
176  const std::string &requestId, Json &request) const
177 {
178  if(!IsRequestId(requestId)) return Error(404, "unknown stage request");
179 
180  std::ifstream input(m_requestsRoot / (requestId + ".json"),
181  std::ios::binary);
182  if(!input) return Error(404, "unknown stage request");
183  try
184  {
185  input >> request;
186  }
187  catch(const std::exception &)
188  {
189  return Error(500, "could not read the stage request");
190  }
191  return {};
192 }
193 
194 Json XrdHttpTapeApiStore::StatusResponse(const Json &request)
195 {
196  Json response = {
197  {"id", request.at("id")},
198  {"createdAt", request.at("createdAt")},
199  {"startedAt", request.at("startedAt")},
200  {"files", Json::array()}
201  };
202  if(request.contains("completedAt"))
203  {
204  response["completedAt"] = request["completedAt"];
205  }
206 
207  for(const auto &stored : request.at("files"))
208  {
209  Json file = {{"path", stored.at("path")}, {"state", stored.at("state")}};
210  for(const char *field : {"startedAt", "finishedAt", "error"})
211  {
212  if(stored.contains(field)) file[field] = stored[field];
213  }
214  response["files"].push_back(std::move(file));
215  }
216  return response;
217 }
218 
220  const Json &files, std::string &requestId)
221 {
222  std::lock_guard<std::mutex> lock(m_mutex);
223  requestId = GenerateRequestId();
224  const std::uint64_t createdAt = Now();
225  Json request = {
226  {"id", requestId},
227  {"createdAt", createdAt},
228  {"startedAt", createdAt},
229  {"files", Json::array()}
230  };
231 
232  for(const auto &input : files)
233  {
234  Json file = input;
235  std::string normalized;
236  fs::path archivePath;
237  Status status = ResolvePath(m_archiveRoot,
238  input.at("path").get<std::string>(),
239  normalized, archivePath);
240  file["path"] = normalized;
241  file["startedAt"] = createdAt;
242 
243  fs::path diskPath;
244  if(status) status = ResolvePath(m_diskRoot, normalized,
245  normalized, diskPath);
246  bool archived = false;
247  if(status && !IsRegularFile(archivePath, archived))
248  {
249  status = Error(500, "could not inspect the archived file");
250  }
251  std::error_code error;
252  if(status && archived && fs::file_size(archivePath, error) == 0)
253  {
254  status = Error(400, "zero-length files cannot be staged from tape");
255  }
256  if(error) status = Error(500, "could not inspect the archived file");
257 
258  bool onDisk = false;
259  if(status && !IsRegularFile(diskPath, onDisk))
260  {
261  status = Error(500, "could not inspect the disk file");
262  }
263  if(status && archived && !onDisk)
264  {
265  error.clear();
266  fs::create_directories(diskPath.parent_path(), error);
267  if(!error)
268  {
269  fs::copy_file(archivePath, diskPath, fs::copy_options::overwrite_existing,
270  error);
271  }
272  if(error) status = Error(500, "could not stage the archived file");
273  }
274  file["finishedAt"] = Now();
275  if(status && archived)
276  {
277  file["state"] = "COMPLETED";
278  }
279  else
280  {
281  file["state"] = "FAILED";
282  file["error"] = status ? "file is not stored on tape" : status.message;
283  }
284  request["files"].push_back(std::move(file));
285  }
286 
287  request["completedAt"] = Now();
288  return WriteRequest(request);
289 }
290 
292  const std::string &requestId, Json &response)
293 {
294  std::lock_guard<std::mutex> lock(m_mutex);
295  Json request;
296  const Status status = ReadRequest(requestId, request);
297  if(!status) return status;
298  response = StatusResponse(request);
299  return {};
300 }
301 
302 XrdHttpTapeApiStore::Status XrdHttpTapeApiStore::ValidateRequestPaths(
303  const Json &request, const Json &paths,
304  std::vector<std::string> &normalized) const
305 {
306  std::set<std::string> requestPaths;
307  for(const auto &file : request.at("files"))
308  {
309  requestPaths.insert(file.at("path").get<std::string>());
310  }
311 
312  normalized.clear();
313  for(const auto &path : paths)
314  {
315  std::string value;
316  fs::path ignored;
317  Status status = ResolvePath(m_diskRoot, path.get<std::string>(),
318  value, ignored);
319  if(!status) return status;
320  if(requestPaths.count(value) == 0)
321  {
322  return Error(400, "file does not belong to the stage request");
323  }
324  normalized.push_back(std::move(value));
325  }
326  return {};
327 }
328 
330  const std::string &requestId, const Json &paths)
331 {
332  std::lock_guard<std::mutex> lock(m_mutex);
333  Json request;
334  Status status = ReadRequest(requestId, request);
335  if(!status) return status;
336  std::vector<std::string> normalized;
337  return ValidateRequestPaths(request, paths, normalized);
338 }
339 
341  const std::string &requestId)
342 {
343  std::lock_guard<std::mutex> lock(m_mutex);
344  Json request;
345  Status status = ReadRequest(requestId, request);
346  if(!status) return status;
347 
348  std::error_code error;
349  if(!fs::remove(m_requestsRoot / (requestId + ".json"), error) || error)
350  {
351  return Error(500, "could not delete the stage request");
352  }
353  return {};
354 }
355 
357  const std::string &requestId, const Json &paths)
358 {
359  std::lock_guard<std::mutex> lock(m_mutex);
360  Json request;
361  Status status = ReadRequest(requestId, request);
362  if(!status) return status;
363 
364  std::vector<std::string> normalized;
365  status = ValidateRequestPaths(request, paths, normalized);
366  if(!status) return status;
367 
368  for(const auto &path : normalized)
369  {
370  std::string ignored;
371  fs::path diskPath;
372  status = ResolvePath(m_diskRoot, path, ignored, diskPath);
373  if(!status) return status;
374  std::error_code error;
375  fs::remove(diskPath, error);
376  if(error) return Error(500, "could not release the disk replica");
377  }
378  return {};
379 }
380 
382  const Json &paths, Json &response)
383 {
384  std::lock_guard<std::mutex> lock(m_mutex);
385  response = Json::array();
386  for(const auto &path : paths)
387  {
388  Json item;
389  std::string normalized;
390  fs::path archivePath;
391  Status status = ResolvePath(m_archiveRoot, path.get<std::string>(),
392  normalized, archivePath);
393  item["path"] = status ? normalized : path.get<std::string>();
394 
395  fs::path diskPath;
396  if(status) status = ResolvePath(m_diskRoot, normalized,
397  normalized, diskPath);
398  bool archived = false;
399  if(status && !IsRegularFile(archivePath, archived))
400  {
401  status = Error(500, "could not inspect the archived file");
402  }
403  bool onDisk = false;
404  if(status && !IsRegularFile(diskPath, onDisk))
405  {
406  status = Error(500, "could not inspect the disk file");
407  }
408 
409  if(!status)
410  {
411  item["error"] = status.message;
412  }
413  else if(archived && onDisk)
414  {
415  item["locality"] = "DISK_AND_TAPE";
416  }
417  else if(archived)
418  {
419  item["locality"] = "TAPE";
420  }
421  else if(onDisk)
422  {
423  item["locality"] = "DISK";
424  }
425  else
426  {
427  item["error"] = "file does not exist";
428  }
429  response.push_back(std::move(item));
430  }
431  return {};
432 }
nlohmann::json json
int rename(const char *oldpath, const char *newpath)
@ Error
Status DeleteStage(const std::string &requestId)
Status GetStage(const std::string &requestId, nlohmann::json &response)
Status CancelStage(const std::string &requestId, const nlohmann::json &paths)
Status CreateStage(const nlohmann::json &files, std::string &requestId)
Status ArchiveInfo(const nlohmann::json &paths, nlohmann::json &response)
XrdHttpTapeApiStore(std::filesystem::path root)
Status Release(const std::string &requestId, const nlohmann::json &paths)
static std::string NormalizePath(const std::string &path)