Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions src/borgstore/backends/rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ def request(self, method, url, params=None, data=None, headers=None, timeout=Non
response_headers[key.strip()] = value.strip()

content_length = int(response_headers.get("Content-Length", "0"))
response_body = self.process.stdout.read(content_length) if content_length else b""
response_body = self.process.stdout.read(content_length) if method.upper() != "HEAD" and content_length else b""

response = requests.Response()
response.status_code = status_code
Expand Down Expand Up @@ -376,7 +376,9 @@ def info(self, name: str) -> ItemInfo:
self._handle_response(response, name) # raises!
exists = response.status_code == HTTP.OK
is_dir = response.headers.get("X-BorgStore-Is-Directory") == "true"
return ItemInfo(name=name, exists=exists, size=int(response.headers.get("Content-Length", 0)), directory=is_dir)
atime = float(response.headers.get("X-BorgStore-Atime", 0))
size = int(response.headers.get("Content-Length", 0)) if exists else 0
return ItemInfo(name=name, exists=exists, size=size, directory=is_dir, atime=atime)

def load(self, name: str, *, size=None, offset=0) -> bytes:
self._assert_open()
Expand Down Expand Up @@ -460,4 +462,10 @@ def list(self, name: str) -> Iterator[ItemInfo]:
response = self._request("get", self._url(name) + "/") # trailing "/" needed to get list
self._handle_response(response, name)
for entry in response.json():
yield ItemInfo(name=entry["name"], exists=True, size=entry["size"], directory=entry.get("directory", False))
yield ItemInfo(
name=entry["name"],
exists=True,
size=entry["size"],
directory=entry.get("directory", False),
atime=entry.get("atime", 0),
)
7 changes: 2 additions & 5 deletions src/borgstore/server/rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,10 +296,6 @@ def do_DELETE(self):

@checks_and_logging
def do_HEAD(self):
if not self.name:
self.send_error(HTTP.BAD_REQUEST, "Bad Request")
return

try:
with self.server.backend:
info = self.server.backend.info(self.name)
Expand All @@ -310,6 +306,7 @@ def do_HEAD(self):
headers={
"Content-Length": str(info.size),
"X-BorgStore-Is-Directory": "true" if info.directory else "false",
"X-BorgStore-Atime": str(info.atime),
},
)
except Exception as e:
Expand All @@ -324,7 +321,7 @@ def do_GET(self):
# [{"name": "...", "size": ...}, ...]
with self.server.backend:
items = (
{"name": item.name, "size": item.size, "directory": item.directory}
{"name": item.name, "size": item.size, "directory": item.directory, "atime": item.atime}
for item in self.server.backend.list(self.name)
)
json_data = json.dumps(list(items), indent=2)
Expand Down
2 changes: 1 addition & 1 deletion src/borgstore/utils/nesting.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def split_key(name: str) -> tuple[Optional[str], str]:

def nest(name: str, levels: int, *, add_suffix: Optional[str] = None) -> str:
"""namespace/12345678 --2 levels--> namespace/12/34/12345678"""
if levels > 0:
if levels > 0 and name:
namespace, key = split_key(name)
parts = [key[2 * level : 2 * level + 2] for level in range(levels)]
parts.append(key)
Expand Down
2 changes: 2 additions & 0 deletions tests/test_nesting.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ def test_split_key(name, base, key):
("data/12345678", 2, False, "data/12/34/12345678"),
("data/12345678", 3, False, "data/12/34/56/12345678"),
("data/12345678", 3, True, "data/12/34/56/12345678.del"),
("", 1, False, ""),
("", 2, False, ""),
],
)
def test_nest(key, levels, deleted, nested_key):
Expand Down
71 changes: 63 additions & 8 deletions tests/test_server_rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from borgstore.backends.rest import get_rest_backend
from borgstore.backends.posixfs import get_file_backend
from borgstore.backends.errors import ObjectNotFound, BackendAlreadyExists, QuotaExceeded
from borgstore.store import get_backend
from borgstore.store import get_backend, Store


def start_server(backend_url, address, port, username=None, password=None, permissions=None, quota=None):
Expand Down Expand Up @@ -76,7 +76,9 @@ def test_rest_server_basic_ops(rest_server_with_auth):
be.delete("test/item1")
with pytest.raises(ObjectNotFound):
be.load("test/item1")
assert not be.info("test/item1").exists
info = be.info("test/item1")
assert not info.exists
assert info.size == 0

finally:
be.close()
Expand Down Expand Up @@ -613,29 +615,82 @@ def do_request(method, path, body=b""):

# Read body
resp_body = b""
if "Content-Length" in resp_headers:
if method.upper() != "HEAD" and "Content-Length" in resp_headers:
resp_body = proc.stdout.read(int(resp_headers["Content-Length"]))
return status, resp_body
return status, resp_body, resp_headers

try:
# 1. Create store
status, body = do_request("POST", "/?cmd=create")
status, body, headers = do_request("POST", "/?cmd=create")
assert status == 200

# 2. Store something
item_data = b"stdio data"
status, body = do_request("POST", "/item1", body=item_data)
status, body, headers = do_request("POST", "/item1", body=item_data)
assert status == 200

# 3. List the store
status, body = do_request("GET", "/")
status, body, headers = do_request("GET", "/")
assert status == 200
items = json.loads(body.decode("utf-8"))
assert any(item["name"] == "item1" for item in items)
assert any(item["name"] == "item1" and item.get("atime", 0) > 0 for item in items)

# 4. Info (HEAD)
status, body, headers = do_request("HEAD", "/item1")
assert status == 200
assert body == b""
assert float(headers.get("X-BorgStore-Atime", 0)) > 0

# 5. Info for nonexistent (HEAD)
status, body, headers = do_request("HEAD", "/nonexistent")
assert status == 404
assert body == b""

finally:
proc.stdin.close()
try:
proc.wait(timeout=2)
except subprocess.TimeoutExpired:
proc.kill()


def test_rest_url(tmp_path):
repo_path = tmp_path / "repo"
# Use rest: URL with stdio backend (empty host)
url = f"rest:///{repo_path}"

# Use levels=0 to avoid root nesting issues if they arise
config = {"": {"levels": [0]}}
store = Store(url, config=config)
store.create()

with store:
item_name = "test-item"
item_data = b"some data"
store.store(item_name, item_data)

# Test Store.info which calls Backend.info (HEAD)
# This used to hang.
info = store.info(item_name)
assert info.exists
assert info.size == len(item_data)
assert info.atime > 0

# Test listing
items = list(store.list(""))
assert len(items) == 1
assert items[0].name == item_name
assert items[0].atime > 0

# Test nonexistent item
# This also used to hang if it returned a 404 with a body.
info_none = store.info("nonexistent")
assert not info_none.exists
assert info_none.size == 0

# Test directory info (root)
info_root = store.info("")
assert info_root.exists
assert info_root.directory

store.destroy()
Loading