Skip to content

Commit 153b044

Browse files
committed
Bug fixes
1 parent 6cf3b9a commit 153b044

4 files changed

Lines changed: 91 additions & 27 deletions

File tree

‎homeway/homeway/commandhandler.py‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -241,13 +241,14 @@ def ProcessCommand(self, commandPath:str, jsonObj_CanBeNone:Optional[Dict[str, A
241241
domainsFilterRaw = jsonObj_CanBeNone.get("FilterDomains", None)
242242
if domainsFilterRaw is not None:
243243
if isinstance(domainsFilterRaw, list) and all(isinstance(d, str) for d in domainsFilterRaw):
244-
domainsFilter = domainsFilterRaw
244+
if len(domainsFilterRaw) > 0:
245+
domainsFilter = domainsFilterRaw
245246
else:
246-
return CommandResponse.Error(CommandHandler.c_CommandError_ArgParseFailure, "'DomainsFilter' must be a list of strings.")
247+
return CommandResponse.Error(CommandHandler.c_CommandError_ArgParseFailure, "'FilterDomains' must be a list of strings.")
247248

248-
allEntities, allStates = self.HomeContext.GetFullDeviceAndEntityTree(forceRefresh, includeStates, domainsFilter)
249+
allEntities, allStates, labels = self.HomeContext.GetFullDeviceAndEntityTree(forceRefresh, includeStates, domainsFilter)
249250
successful = allEntities is not None
250-
return CommandResponse.Success({"Success": successful, "Floors": allEntities, "States": allStates})
251+
return CommandResponse.Success({"Success": successful, "Floors": allEntities, "States": allStates, "Labels": labels})
251252

252253
# Used by Sage and MCP to get the current home context and live state in a single round trip.
253254
if commandPathLower.startswith("get-live-context"):
@@ -503,7 +504,7 @@ def HandleReadTextFileCommand(self, jsonArgs:Dict[str, Any]) -> CommandResponse:
503504
return textEncodingError
504505

505506
rawStartLine = self._GetCommandArg(jsonArgs, "StartLine")
506-
startLine, startLineError = self._ParseOptionalNonNegativeInt(rawStartLine, "StartLine")
507+
startLine, startLineError = self._ParseOptionalPositiveInt(rawStartLine, "StartLine")
507508
if startLineError is not None:
508509
return startLineError
509510

@@ -543,6 +544,8 @@ def HandleWriteFileCommand(self, jsonArgs:Dict[str, Any]) -> CommandResponse:
543544
base64Data, base64DataError = self._ParseOptionalString(rawBase64Data, "Base64Data")
544545
if base64DataError is not None:
545546
return base64DataError
547+
if text is not None and base64Data is not None:
548+
return CommandResponse.Error(CommandHandler.c_CommandError_ArgParseFailure, "Only one of 'Text' or 'Base64Data' can be provided.")
546549

547550
rawTextEncoding = self._GetCommandArg(jsonArgs, "TextEncoding")
548551
textEncoding, textEncodingError = self._ParseOptionalString(rawTextEncoding, "TextEncoding")
@@ -581,8 +584,16 @@ def HandleMoveFileCommand(self, jsonArgs:Dict[str, Any]) -> CommandResponse:
581584
rawCopy = self._GetCommandArg(jsonArgs, "Copy")
582585
copy = self._ParseOptionalBool(rawCopy, False)
583586

587+
rawOverride = self._GetCommandArg(jsonArgs, "Override")
588+
override = self._ParseOptionalBool(rawOverride, False)
589+
590+
rawExpectedSha256 = self._GetCommandArg(jsonArgs, "ExpectedSha256")
591+
expectedSha256, expectedSha256Error = self._ParseOptionalString(rawExpectedSha256, "ExpectedSha256")
592+
if expectedSha256Error is not None:
593+
return expectedSha256Error
594+
584595
try:
585-
return CommandResponse.Success(self.HomeAssistantFileSystem.MoveFile(rawPath, rawNewPath, copy))
596+
return CommandResponse.Success(self.HomeAssistantFileSystem.MoveFile(rawPath, rawNewPath, copy, override, expectedSha256))
586597
except Exception as e:
587598
return self._FileSystemExceptionToCommandResponse(e)
588599

@@ -618,8 +629,11 @@ def HandleDeleteFileCommand(self, jsonArgs:Dict[str, Any]) -> CommandResponse:
618629
if not isinstance(rawPath, str):
619630
return CommandResponse.Error(CommandHandler.c_CommandError_ArgParseFailure, "'Path' must be a string.")
620631

632+
rawRecursive = self._GetCommandArg(jsonArgs, "Recursive")
633+
recursive = self._ParseOptionalBool(rawRecursive, False)
634+
621635
try:
622-
return CommandResponse.Success(self.HomeAssistantFileSystem.DeleteFile(rawPath))
636+
return CommandResponse.Success(self.HomeAssistantFileSystem.DeleteFile(rawPath, recursive))
623637
except Exception as e:
624638
return self._FileSystemExceptionToCommandResponse(e)
625639

‎homeway/homeway/interfaces.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,15 +105,15 @@ def WriteFile(self, path:str, text:Optional[str], base64Data:Optional[str], text
105105
pass
106106

107107
@abstractmethod
108-
def MoveFile(self, path:str, newPath:str, copy:bool) -> Dict[str, Any]:
108+
def MoveFile(self, path:str, newPath:str, copy:bool, override:bool, expectedSha256:Optional[str]) -> Dict[str, Any]:
109109
pass
110110

111111
@abstractmethod
112112
def PatchFile(self, path:str, unifiedDiffPatch:str, expectedSha256:Optional[str]) -> Dict[str, Any]:
113113
pass
114114

115115
@abstractmethod
116-
def DeleteFile(self, path:str) -> Dict[str, Any]:
116+
def DeleteFile(self, path:str, recursive:bool) -> Dict[str, Any]:
117117
pass
118118

119119

@@ -139,7 +139,7 @@ def GetStatesAndLiveContext(self) -> Tuple[Optional["CompressionResult"], Option
139139
# Gets the full device and entity tree stored in our cache.
140140
# Optionally also gets the states and can be filtered by domains.
141141
@abstractmethod
142-
def GetFullDeviceAndEntityTree(self, forceRefresh: bool, includeStates:bool=False, domainsFilter:Optional[List[str]]=None) -> Tuple[Optional[List[Dict[str, Any]]], Optional[List[Dict[str, Any]]]]:
142+
def GetFullDeviceAndEntityTree(self, forceRefresh: bool, includeStates:bool=False, domainsFilter:Optional[List[str]]=None) -> Tuple[Optional[List[Dict[str, Any]]], Optional[List[Dict[str, Any]]], Optional[List[Dict[str, Any]]]]:
143143
pass
144144

145145
# Looks up a full entity dict by its entity ID, or None if not found.

‎homeway/homeway_linuxhost/ha/filesystem.py‎

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,8 @@ def ListFiles(self, path:str, recursive:bool) -> Dict[str, Any]:
6868
self._AddFileListEntry(files, rootPath, entryPath, entryName)
6969

7070
return {
71-
"Path": self._ToResponseRelativePath(normalizedPath),
72-
"Files": files,
71+
"path": self._ToResponseRelativePath(normalizedPath),
72+
"files": files,
7373
}
7474

7575

@@ -106,7 +106,11 @@ def ReadTextFile(self, path:str, textEncoding:Optional[str], startLine:Optional[
106106
fileSize = os.path.getsize(targetPath)
107107
lines, fullLineCount, readStartLine = self._ReadTextLines(targetPath, textEncoding, startLine, maxLines, tailLines)
108108
linesRead = len(lines)
109-
readEndLine = readStartLine + linesRead - 1
109+
if linesRead == 0:
110+
readStartLine = 0
111+
readEndLine = 0
112+
else:
113+
readEndLine = readStartLine + linesRead - 1
110114
isPartialRead = fullLineCount > 0 and (readStartLine > 1 or readEndLine < fullLineCount)
111115

112116
# Note! These properties are used by the MCP server and explicitly deserialized by the server, so they must stay in sync!
@@ -147,7 +151,7 @@ def WriteFile(self, path:str, text:Optional[str], base64Data:Optional[str], text
147151
}
148152

149153

150-
def MoveFile(self, path:str, newPath:str, copy:bool) -> Dict[str, Any]:
154+
def MoveFile(self, path:str, newPath:str, copy:bool, override:bool, expectedSha256:Optional[str]) -> Dict[str, Any]:
151155
rootPath = self._GetRootPath(True)
152156
sourceOperationName = "copy" if copy else "move"
153157
_, targetPath = self._ResolveExistingFilePath(rootPath, path, sourceOperationName)
@@ -157,6 +161,11 @@ def MoveFile(self, path:str, newPath:str, copy:bool) -> Dict[str, Any]:
157161
if os.path.realpath(targetPath) == os.path.realpath(newTargetPath):
158162
raise ValueError("'NewPath' must be different from 'Path'.")
159163

164+
expectedSha256 = self._NormalizeExpectedSha256(expectedSha256)
165+
self._ValidateExpectedSha256(targetPath, expectedSha256)
166+
if override is False and os.path.exists(newTargetPath):
167+
raise FileExistsError("Destination file already exists and 'Override' is false.")
168+
160169
fileSize = os.path.getsize(targetPath)
161170
if copy:
162171
shutil.copy2(targetPath, newTargetPath)
@@ -186,7 +195,7 @@ def PatchFile(self, path:str, unifiedDiffPatch:str, expectedSha256:Optional[str]
186195
try:
187196
applied = patchSet.apply()
188197
if applied is False:
189-
raise RuntimeError("Patch could not be applied.")
198+
raise RuntimeError("Patch context did not match the current file.")
190199
if os.path.exists(targetPath) is False:
191200
raise RuntimeError("Patch removed file unexpectedly.")
192201
self._ReadUtf8TextFileBytesForPatch(targetPath, "Patched file")
@@ -203,15 +212,26 @@ def PatchFile(self, path:str, unifiedDiffPatch:str, expectedSha256:Optional[str]
203212
}
204213

205214

206-
def DeleteFile(self, path:str) -> Dict[str, Any]:
215+
def DeleteFile(self, path:str, recursive:bool) -> Dict[str, Any]:
207216
rootPath = self._GetRootPath(True)
208-
_, targetPath = self._ResolveExistingFilePath(rootPath, path, "remove")
209-
210-
os.remove(targetPath)
217+
_, targetPath = self._ResolveExistingPath(rootPath, path, "remove")
218+
219+
isDirectory = os.path.isdir(targetPath)
220+
if isDirectory:
221+
if recursive:
222+
self._ValidateDirectoryTreeForDelete(rootPath, targetPath)
223+
shutil.rmtree(targetPath)
224+
else:
225+
if len(os.listdir(targetPath)) > 0:
226+
raise OSError("Directory is not empty. Set 'Recursive' to true to delete it.")
227+
os.rmdir(targetPath)
228+
else:
229+
os.remove(targetPath)
211230

212231
# Note! These properties are used by the MCP server and explicitly deserialized by the server, so they must stay in sync!
213232
return {
214233
"deleted": True,
234+
"is_directory": isDirectory,
215235
}
216236

217237

@@ -234,15 +254,20 @@ def _ResolvePath(self, rootPath:str, rawPath:str, allowRoot:bool) -> Tuple[str,
234254

235255

236256
def _ResolveExistingFilePath(self, rootPath:str, rawPath:str, operationName:str) -> Tuple[str, str]:
257+
normalizedPath, targetPath = self._ResolveExistingPath(rootPath, rawPath, operationName)
258+
if os.path.isfile(targetPath) is False:
259+
raise ValueError("'Path' must reference a file.")
260+
return normalizedPath, targetPath
261+
262+
263+
def _ResolveExistingPath(self, rootPath:str, rawPath:str, operationName:str) -> Tuple[str, str]:
237264
normalizedPath, targetPath = self._ResolvePath(rootPath, rawPath, False)
238265
if self._IsDeniedFileName(normalizedPath):
239266
raise PermissionError("Access to this file is denied.")
240267
if os.path.exists(targetPath) is False:
241268
raise FileNotFoundError("File does not exist.")
242269
if os.path.islink(targetPath):
243270
raise PermissionError(f"Refusing to {operationName} a symbolic link.")
244-
if os.path.isfile(targetPath) is False:
245-
raise ValueError("'Path' must reference a file.")
246271
return normalizedPath, targetPath
247272

248273

@@ -291,9 +316,11 @@ def _GetTextEncoding(self, textEncoding:Optional[str]) -> str:
291316

292317

293318
def _GetTextStartLine(self, startLine:Optional[int]) -> int:
294-
# StartLine is 1-based for callers, but accept 0 as the beginning like the byte offset API did.
295-
if startLine is None or startLine == 0:
319+
# StartLine is 1-based for callers.
320+
if startLine is None:
296321
return 1
322+
if startLine <= 0:
323+
raise ValueError("'StartLine' must be greater than zero.")
297324
return startLine
298325

299326

@@ -339,15 +366,17 @@ def _ReadTextLineRange(self, targetPath:str, textEncoding:str, startLine:int, ma
339366

340367

341368
def _GetWriteContentBytes(self, text:Optional[str], base64Data:Optional[str], textEncoding:Optional[str]) -> bytes:
369+
if text is not None and base64Data is not None:
370+
raise ValueError("Only one of 'Text' or 'Base64Data' can be provided.")
371+
if text is None and base64Data is None:
372+
raise ValueError("One of 'Text' or 'Base64Data' must be provided.")
342373
if text is not None:
343374
if textEncoding is None:
344375
textEncoding = "utf-8"
345376
try:
346377
return text.encode(textEncoding)
347378
except LookupError as e:
348379
raise ValueError(f"Unknown text encoding '{textEncoding}'.") from e
349-
if base64Data is None:
350-
raise ValueError("'Base64Data' must be provided when 'Format' is 'data'.")
351380
try:
352381
return base64.b64decode(base64Data, validate=True)
353382
except Exception as e:
@@ -423,6 +452,23 @@ def _RestoreFileBytesAfterFailedPatch(self, targetPath:str, fileBytes:bytes) ->
423452
f.write(fileBytes)
424453

425454

455+
def _ValidateDirectoryTreeForDelete(self, rootPath:str, targetPath:str) -> None:
456+
for currentRoot, dirNames, fileNames in os.walk(targetPath):
457+
if self._IsPathInsideRoot(rootPath, currentRoot) is False:
458+
raise PermissionError("Directory contains a path outside of the Home Assistant config directory.")
459+
for dirName in dirNames:
460+
entryPath = os.path.join(currentRoot, dirName)
461+
if os.path.islink(entryPath):
462+
raise PermissionError("Refusing to delete a directory containing a symbolic link.")
463+
for fileName in fileNames:
464+
entryPath = os.path.join(currentRoot, fileName)
465+
normalizedPath = os.path.relpath(entryPath, rootPath)
466+
if os.path.islink(entryPath):
467+
raise PermissionError("Refusing to delete a directory containing a symbolic link.")
468+
if self._IsDeniedFileName(normalizedPath):
469+
raise PermissionError("Refusing to delete a directory containing a denied file.")
470+
471+
426472
def _HashFile(self, targetPath:str) -> str:
427473
sha256 = hashlib.sha256()
428474
with open(targetPath, "rb") as f:

‎homeway/homeway_linuxhost/ha/homecontext.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ def __init__(self, logger:logging.Logger, haConnection:Connection, eventHandler:
7171

7272
# These cache the full device and entity tree for use by the server and other components.
7373
self.FullDeviceAndEntityTree:Optional[List[Dict[str, Any]]] = None
74+
self.FullLabels:Optional[List[Dict[str, Any]]] = None
7475
# This is the same entity that are in the full tree, but just dumped into a map for fast lookups.
7576
self.FullEntityMap:Optional[Dict[str, Dict[str, Any]]] = None
7677

@@ -107,9 +108,11 @@ def GetSageHomeContext(self) -> Optional[CompressionResult]:
107108

108109

109110
# Returns the full floor -> area -> device -> entity tree.
110-
def GetFullDeviceAndEntityTree(self, forceRefresh: bool, includeStates:bool=False, domainsFilter:Optional[List[str]]=None) -> Tuple[Optional[List[Dict[str, Any]]], Optional[List[Dict[str, Any]]]]:
111+
def GetFullDeviceAndEntityTree(self, forceRefresh: bool, includeStates:bool=False, domainsFilter:Optional[List[str]]=None) -> Tuple[Optional[List[Dict[str, Any]]], Optional[List[Dict[str, Any]]], Optional[List[Dict[str, Any]]]]:
111112
# Always start with the full entity tree.
112113
allEntities = self._GetFullDeviceAndEntityTree(forceRefresh)
114+
with self.CacheLock:
115+
labels = self.FullLabels
113116

114117
# If we got a result, filter if needed.
115118
if allEntities is not None:
@@ -123,7 +126,7 @@ def GetFullDeviceAndEntityTree(self, forceRefresh: bool, includeStates:bool=Fals
123126
(states, _) = self._FilterStateList(states, useSageFiltering=False, domainsFilter=domainsFilter)
124127

125128
# Done!
126-
return (allEntities, states)
129+
return (allEntities, states, labels)
127130

128131

129132
# Returns the full floor -> area -> device -> entity tree.
@@ -704,6 +707,7 @@ def _HandleAllObjectsResult(self, result:"HomeContextQueryResult") -> Tuple[int,
704707
self.AssistantDeviceContexts = assistDeviceContexts
705708
# This is the full device and entity tree for other components that need it.
706709
self.FullDeviceAndEntityTree = fullDeviceAndEntityTree
710+
self.FullLabels = labelsList
707711
# This is the full entity map for fast lookups.
708712
self.FullEntityMap = fullEntityMap
709713
self.CacheUpdatedEvent.set()

0 commit comments

Comments
 (0)